From 2a8f9e2ba2b7e06f195cebcad6b048f26b494650 Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Sun, 16 Nov 2025 23:32:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../module/tik/file/service/TikUserFileServiceImpl.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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..6fddcada83 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 @@ -115,7 +115,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,7 +129,7 @@ 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) { + String fileUrl, String filePath, String coverBase64, String baseDirectory, Long infraFileId) { // 7. 处理视频封面(如果有前端传递的 base64 封面,先处理封面再插入主记录) String coverUrl = null; if (StrUtil.isNotBlank(coverBase64) && StrUtil.containsIgnoreCase(file.getContentType(), "video")) { @@ -180,7 +180,7 @@ public class TikUserFileServiceImpl implements TikUserFileService { // 8. 创建文件记录(保存完整路径,包含封面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) From 81f531b51b605fd37ea279805c49957f192a81b7 Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Tue, 18 Nov 2025 23:30:31 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/backend.mdc | 23 +- CLAUDE.md | 511 ++++++ frontend/app/web-gold/src/api/mix.js | 46 + frontend/app/web-gold/src/api/voice.js | 110 ++ .../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 - .../module/tik/enmus/ErrorCodeConstants.java | 8 + .../file/service/TikUserFileServiceImpl.java | 168 +- .../service/TikFileTransCharacters.java | 36 +- .../tik/tikhup/service/TikHupServiceImpl.java | 37 +- .../tik/voice/client/CosyVoiceClient.java | 178 ++ .../tik/voice/client/LatentsyncClient.java | 141 ++ .../voice/client/dto/CosyVoiceTtsRequest.java | 54 + .../voice/client/dto/CosyVoiceTtsResult.java | 37 + .../client/dto/LatentsyncSubmitRequest.java | 34 + .../client/dto/LatentsyncSubmitResponse.java | 39 + .../tik/voice/config/CosyVoiceProperties.java | 74 + .../voice/config/LatentsyncProperties.java | 78 + .../AppTikLatentsyncController.java | 38 + .../controller/AppTikUserVoiceController.java | 95 + .../voice/dal/dataobject/TikUserVoiceDO.java | 59 + .../voice/dal/mysql/TikUserVoiceMapper.java | 26 + .../tik/voice/service/LatentsyncService.java | 20 + .../voice/service/LatentsyncServiceImpl.java | 42 + .../voice/service/TikUserVoiceService.java | 75 + .../service/TikUserVoiceServiceImpl.java | 864 +++++++++ .../voice/util/ByteArrayMultipartFile.java | 69 + .../voice/vo/AppTikLatentsyncSubmitReqVO.java | 37 + .../vo/AppTikLatentsyncSubmitRespVO.java | 22 + .../voice/vo/AppTikUserVoiceCreateReqVO.java | 38 + .../voice/vo/AppTikUserVoicePageReqVO.java | 23 + .../tik/voice/vo/AppTikUserVoiceRespVO.java | 48 + .../voice/vo/AppTikUserVoiceUpdateReqVO.java | 36 + .../tik/voice/vo/AppTikVoicePreviewReqVO.java | 43 + .../voice/vo/AppTikVoicePreviewRespVO.java | 26 + .../tik/voice/vo/AppTikVoiceTtsReqVO.java | 46 + .../tik/voice/vo/AppTikVoiceTtsRespVO.java | 29 + .../service/LatentsyncServiceImplTest.java | 62 + .../src/main/resources/application-local.yaml | 7 +- .../src/main/resources/application.yaml | 7 + 52 files changed, 4627 insertions(+), 2251 deletions(-) create mode 100644 CLAUDE.md create mode 100644 frontend/app/web-gold/src/api/mix.js create mode 100644 frontend/app/web-gold/src/api/voice.js 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 create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/CosyVoiceClient.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/LatentsyncClient.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/dto/CosyVoiceTtsRequest.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/dto/CosyVoiceTtsResult.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/dto/LatentsyncSubmitRequest.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/dto/LatentsyncSubmitResponse.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/config/CosyVoiceProperties.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/config/LatentsyncProperties.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/controller/AppTikLatentsyncController.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/controller/AppTikUserVoiceController.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/dataobject/TikUserVoiceDO.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/mysql/TikUserVoiceMapper.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncService.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncServiceImpl.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/TikUserVoiceService.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/TikUserVoiceServiceImpl.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/util/ByteArrayMultipartFile.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikLatentsyncSubmitReqVO.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikLatentsyncSubmitRespVO.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoiceCreateReqVO.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoicePageReqVO.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoiceRespVO.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoiceUpdateReqVO.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoicePreviewReqVO.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoicePreviewRespVO.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoiceTtsReqVO.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoiceTtsRespVO.java create mode 100644 yudao-module-tik/src/test/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncServiceImplTest.java diff --git a/.cursor/rules/backend.mdc b/.cursor/rules/backend.mdc index 102be8e118..5aba776dd9 100644 --- a/.cursor/rules/backend.mdc +++ b/.cursor/rules/backend.mdc @@ -88,24 +88,7 @@ yudao-module-{模块名}/ ### 目录结构示例 -#### 示例 1:简单模块(tikhup) -``` -tikhup/ -├── controller/ -│ └── TikHupController.java -├── service/ -│ ├── TikHupService.java -│ ├── TikHupServiceImpl.java -│ └── TikFileTransCharacters.java -├── mapper/ -│ ├── TikPromptMapper.java -│ └── TikTokenMapper.java -└── vo/ - ├── TikPromptVO.java - └── TikTokenVO.java -``` - -#### 示例 2:完整模块(file) +#### 示例 2:模块 ``` file/ ├── controller/ @@ -131,8 +114,8 @@ file/ ### 目录结构原则 1. **统一性**:同一模块内保持结构一致 -2. **简洁性**:使用 `mapper/` 和 `dataobject/` 包,结构清晰 -3. **可选性**:没有 DO 对象时可以省略 `dataobject/` 包 +2. **简洁性**:使用 `mapper/`,结构清晰 +3. **可选性**:省略 `dataobject/` 包 4. **可扩展性**:预留扩展空间,便于后续功能扩展 ## Controller 层规范 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..d27ddc69a1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,511 @@ +# CLAUDE.md + +本文档为 Claude Code (claude.ai/code) 在此仓库中处理代码提供指导。 + +## 项目概览 + +**Yudao(芋道)** - 基于 Spring Boot 的快速开发平台,采用多模块架构。这是 Yudao 平台的 AI/媒体重点部署版本,具备数字人生成、语音克隆、视频混剪和内容分析能力。 + +### 核心技术栈 + +**后端:** +- Java 17 + Spring Boot 3.5.5 +- Maven 构建管理 +- MyBatis Plus 3.5.14 + Dynamic Datasource ORM +- Redis + Redisson 缓存 +- Spring Security 6.5.2 认证 +- Flowable 7.0.1 工作流 +- Springdoc/OpenAPI 文档 + +**前端:** +- Vue.js 3.5.22 + Composition API +- Vite 7.1.7 构建工具 +- Ant Design Vue 4.2.6 UI组件 +- TypeScript 类型安全 +- Pinia 3.0.3 状态管理 +- TailwindCSS 4.1.14 样式 + +**数据库与基础设施:** +- MySQL 8.0+(主要) +- 支持 PostgreSQL、Oracle、SQL Server、DM、KingbaseES、OpenGauss、TiDB +- Redis 缓存 +- Docker 容器化 + +## 项目结构 + +``` +/d/projects/sionrui/ +├── yudao-dependencies/ # Maven 依赖版本管理 +├── yudao-framework/ # 框架组件和 Spring Boot 启动器 +├── yudao-server/ # 主应用服务器(端口 9900) +├── yudao-module-system/ # 系统管理(用户、角色、权限) +├── yudao-module-infra/ # 基础设施(文件、配置、任务) +├── yudao-module-member/ # 会员中心 +├── yudao-module-pay/ # 支付系统 +├── yudao-module-ai/ # AI/ML 功能(聊天、图像、知识、音乐) +├── yudao-module-tik/ # Tik/媒体模块(语音克隆、头像、视频) +├── frontend/app/web-gold/ # Vue.js 前端 +├── sql/ # 数据库模式 +├── script/ # 构建和部署脚本 +└── docs/ # 文档 +``` + +## 常用开发命令 + +### 后端(Maven) + +**构建和运行:** +```bash +# 构建项目 +mvn clean package -DskipTests + +# 运行特定模块的测试 +mvn test -pl yudao-module-tik + +# 启动服务器 +cd yudao-server && mvn spring-boot:run -Dspring-boot.run.profiles=local + +# 使用特定配置构建 +mvn clean package -Pdev -DskipTests +``` + +**代码生成:** +- 内置 CRUD 操作代码生成器 +- 生成 Java、Vue、SQL 脚本和 API 文档 +- 支持单表、树表、主子表模式 + +### 前端(Vue.js) + +**开发:** +```bash +cd frontend/app/web-gold + +# 安装依赖 +npm install + +# 启动开发服务器(代理到后端 9900 端口) +npm run dev + +# 生产构建 +npm run build + +# 代码检查 +npm run lint + +# 代码格式化 +npm run format +``` + +**可用脚本:** +- `dev` - 带热重载的开发服务器 +- `build` - 生产构建 +- `preview` - 预览生产构建 +- `lint:oxlint` - 运行 OxLint 并自动修复 +- `lint:eslint` - 运行 ESLint 并自动修复 +- `lint` - 运行所有检查器 +- `format` - 使用 Prettier 格式化代码 + +### Docker + +**使用 Docker Compose:** +```bash +# 启动所有服务(MySQL、Redis、Server、Admin) +cd script/docker +docker-compose up -d + +# 启动特定服务 +docker-compose up -d mysql redis +``` + +**手动 Docker 构建:** +```bash +# 后端 +cd yudao-server +docker build -t yudao-server . + +# 前端 +cd frontend/app/web-gold +docker build -t web-gold . +``` + +## 模块架构 + +### 后端模块结构模式 + +每个模块都遵循一致的分层架构: +``` +module/ +├── controller/ # REST 控制器(admin-api/、app/) +├── service/ # 业务逻辑 + 接口 +│ ├── {Xxx}Service.java # 接口 +│ └── {Xxx}ServiceImpl.java # 实现 +├── dal/ # 数据访问层 +│ ├── mysql/ # MyBatis Mappers 和 DO 类 +│ └── redis/ # Redis 操作 +├── client/ # 外部 API 客户端 +├── config/ # 配置类 +├── util/ # 工具类 +└── vo/ # 值对象 + ├── {Xxx}SaveReqVO.java # 创建请求 + ├── {Xxx}PageReqVO.java # 分页请求 + ├── {Xxx}UpdateReqVO.java # 更新请求 + └── {Xxx}RespVO.java # 响应 +``` + +**核心模块:** + +1. **yudao-module-tik** - 媒体/AI 功能 + - `voice/` - 语音克隆(CosyVoice、Latentsync) + - `file/` - 带 OSS 集成的文件管理 + - `chat/` - 对话管理 + - `media/` - 媒体处理 + - `quota/` - 配额管理 + +2. **yudao-module-ai** - AI/ML 能力 + - 聊天补全 API + - 图像生成(Midjourney) + - 音乐生成(Suno) + - 带向量搜索的知识库 + +3. **yudao-module-system** - 核心系统功能 + - 用户/角色/权限管理 + - 多租户支持 + - 审计日志 + +### 前端结构 + +``` +frontend/app/web-gold/src/ +├── api/ # API 服务层 +│ ├── axios/ # Axios 拦截器 +│ ├── voice.js # 语音相关 API +│ └── mix.js # 视频混剪 API +├── components/ # 可复用 Vue 组件 +├── router/ +│ └── index.js # Vue Router 配置 +├── stores/ +│ └── voiceCopy.js # Pinia 状态管理 +├── views/ +│ ├── dh/ # 数字人功能 +│ │ ├── Avatar.vue +│ │ ├── Video.vue +│ │ └── VoiceCopy.vue +│ ├── material/ # 素材库 +│ └── content-style/# 内容分析 +└── utils/ + └── video-cover.ts # 工具函数 +``` + +**核心路由:** +- `/digital-human/*` - 语音克隆、头像、视频生成 +- `/content-style/*` - 内容分析和基准测试 +- `/trends/*` - 趋势分析 +- `/material/*` - 素材库管理 + +## 配置 + +### 后端配置文件 + +**主配置:** `yudao-server/src/main/resources/application.yaml` +- Spring Boot 配置 +- 数据库连接 +- Redis 设置 +- 安全设置 +- 多租户配置 +- AI 服务 API 密钥 + +**本地开发:** `yudao-server/src/main/resources/application-local.yaml` +- 本地开发覆盖 +- 数据库:`jdbc:mysql://8.155.172.147:3306/sion_rui_dev` +- Redis:`8.155.172.147:6379` +- 端口:9900 + +**配置环境:** +- `local` - 开发(端口 9900) +- `dev` - 开发服务器 +- `prod` - 生产 + +### 前端配置 + +**Vite 配置:** `frontend/app/web-gold/vite.config.js` +- 开发服务器代理到后端 +- 构建配置 +- 插件设置 + +**API 代理:** +- 开发服务器将 `/admin-api` 和 `/api` 代理到 `http://localhost:9900` + +## 数据库模式 + +**位置:** `sql/mysql/` +- 主模式:`ruoyi-vue-pro.sql` (949KB) +- Quartz:`quartz.sql` 用于定时任务 +- 模块特定迁移在各模块文件夹中 + +**模式更新:** +- 将 SQL 迁移添加到 `sql/mysql/` +- 遵循命名约定:`V{version}__{description}.sql` + +## API 文档 + +- **Swagger UI:** `http://localhost:9900/swagger-ui.html` +- **API 文档:** `http://localhost:9900/v3/api-docs` + +**API 路径约定:** +- 管理 API:`/admin-api/{module}/{resource}` +- 应用 API:`/api/{module}/{resource}` +- CRUD 端点: + - 创建:`POST /module/resource/create` + - 更新:`PUT /module/resource/update` + - 删除:`DELETE /module/resource/delete` + - 查询:`GET /module/resource/get?id=xxx` + - 分页:`GET /module/resource/page` + +## 代码风格与规范 + +### 后端(Java) + +**架构层:** +1. **Controller** - 请求处理、验证、调用 Service +2. **Service** - 业务逻辑、事务管理 +3. **Mapper** - 使用 MyBatis Plus 进行数据访问 +4. **VO** - API 请求/响应对象 +5. **DO** - 映射到数据库表的数据对象 + +**关键规范:** +- Mapper 接口继承 `BaseMapperX` +- DO 类继承 `BaseDO` 或 `TenantBaseDO` 以支持多租户 +- 使用 `@PreAuthorize` 进行权限控制 +- 统一使用 `CommonResult` 作为 API 响应 +- Service 方法使用 `@Transactional` 进行写操作 +- 异常代码在 `ErrorCodeConstants` 中,格式为:`MODULE_RESOURCE_ACTION_ERROR` + +**命名规范:** +- Controller:`{Xxx}Controller` 或 `App{Xxx}Controller` +- Service:`{Xxx}Service` 和 `{Xxx}ServiceImpl` +- Mapper:`{Xxx}Mapper` +- VO:`{Xxx}SaveReqVO`、`{Xxx}PageReqVO`、`{Xxx}RespVO` +- DO:`{Xxx}DO` + +### 前端(Vue.js) + +**关键模式:** +- Composition API + ` 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/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 6fddcada83..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()); @@ -130,7 +166,13 @@ 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, Long infraFileId) { - // 7. 处理视频封面(如果有前端传递的 base64 封面,先处理封面再插入主记录) + // 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,7 +220,7 @@ public class TikUserFileServiceImpl implements TikUserFileService { } } - // 8. 创建文件记录(保存完整路径,包含封面URL和Base64) + // 9. 创建文件记录(保存完整路径,包含封面URL和Base64) TikUserFileDO userFile = new TikUserFileDO() .setUserId(userId) .setFileId(infraFileId) // 关联infra_file表,用于后续通过FileService管理文件 @@ -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-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/CosyVoiceClient.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/CosyVoiceClient.java new file mode 100644 index 0000000000..19e674a5ef --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/CosyVoiceClient.java @@ -0,0 +1,178 @@ +package cn.iocoder.yudao.module.tik.voice.client; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.module.tik.voice.client.dto.CosyVoiceTtsRequest; +import cn.iocoder.yudao.module.tik.voice.client.dto.CosyVoiceTtsResult; +import cn.iocoder.yudao.module.tik.voice.config.CosyVoiceProperties; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception0; +import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.VOICE_TTS_FAILED; + +/** + * CosyVoice 客户端 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CosyVoiceClient { + + private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + + private final CosyVoiceProperties properties; + private final ObjectMapper objectMapper; + + private volatile OkHttpClient httpClient; + + /** + * 调用 CosyVoice TTS 接口 + */ + public CosyVoiceTtsResult synthesize(CosyVoiceTtsRequest request) { + if (!properties.isEnabled()) { + throw exception0(VOICE_TTS_FAILED.getCode(), "未配置 CosyVoice API Key"); + } + if (request == null || StrUtil.isBlank(request.getText())) { + throw exception0(VOICE_TTS_FAILED.getCode(), "TTS 文本不能为空"); + } + + try { + String payload = objectMapper.writeValueAsString(buildPayload(request)); + Request httpRequest = new Request.Builder() + .url(properties.getTtsUrl()) + .addHeader("Authorization", "Bearer " + properties.getApiKey()) + .addHeader("Content-Type", "application/json") + .post(RequestBody.create(payload.getBytes(StandardCharsets.UTF_8), JSON)) + .build(); + + try (Response response = getHttpClient().newCall(httpRequest).execute()) { + String body = response.body() != null ? response.body().string() : ""; + if (!response.isSuccessful()) { + log.error("[CosyVoice][TTS失败][status={}, body={}]", response.code(), body); + throw buildException(body); + } + return parseTtsResult(body, request); + } + } catch (ServiceException ex) { + throw ex; + } catch (Exception ex) { + log.error("[CosyVoice][TTS异常]", ex); + throw exception(VOICE_TTS_FAILED); + } + } + + private Map buildPayload(CosyVoiceTtsRequest request) { + Map payload = new HashMap<>(); + String model = StrUtil.blankToDefault(request.getModel(), properties.getDefaultModel()); + payload.put("model", model); + + Map input = new HashMap<>(); + input.put("text", request.getText()); + String voiceId = StrUtil.blankToDefault(request.getVoiceId(), properties.getDefaultVoiceId()); + if (StrUtil.isNotBlank(voiceId)) { + input.put("voice", voiceId); + } + payload.put("input", input); + + Map parameters = new HashMap<>(); + int sampleRate = request.getSampleRate() != null ? request.getSampleRate() : properties.getSampleRate(); + parameters.put("sample_rate", sampleRate); + String format = StrUtil.blankToDefault(request.getAudioFormat(), properties.getAudioFormat()); + parameters.put("format", format); + if (request.getSpeechRate() != null) { + parameters.put("speech_rate", request.getSpeechRate()); + } + if (request.getVolume() != null) { + parameters.put("volume", request.getVolume()); + } + if (request.isPreview()) { + parameters.put("preview", true); + } + payload.put("parameters", parameters); + return payload; + } + + private CosyVoiceTtsResult parseTtsResult(String body, CosyVoiceTtsRequest request) throws Exception { + JsonNode root = objectMapper.readTree(body); + + // 错误响应包含 code 字段 + if (root.has("code")) { + String message = root.has("message") ? root.get("message").asText() : body; + log.error("[CosyVoice][TTS失败][code={}, message={}]", root.get("code").asText(), message); + throw exception0(VOICE_TTS_FAILED.getCode(), message); + } + + JsonNode audioNode = root.path("output").path("audio"); + if (!audioNode.isArray() || audioNode.isEmpty()) { + throw exception0(VOICE_TTS_FAILED.getCode(), "CosyVoice 返回的音频为空"); + } + + JsonNode firstAudio = audioNode.get(0); + String content = firstAudio.path("content").asText(); + if (StrUtil.isBlank(content)) { + throw exception0(VOICE_TTS_FAILED.getCode(), "CosyVoice 返回空音频内容"); + } + + byte[] audioBytes = Base64.getDecoder().decode(content); + CosyVoiceTtsResult result = new CosyVoiceTtsResult(); + result.setAudio(audioBytes); + result.setFormat(firstAudio.path("format").asText(StrUtil.blankToDefault(request.getAudioFormat(), properties.getAudioFormat()))); + result.setSampleRate(firstAudio.path("sample_rate").asInt(request.getSampleRate() != null ? request.getSampleRate() : properties.getSampleRate())); + result.setRequestId(root.path("request_id").asText()); + result.setVoiceId(firstAudio.path("voice").asText(request.getVoiceId())); + return result; + } + + private OkHttpClient getHttpClient() { + if (httpClient == null) { + synchronized (this) { + if (httpClient == null) { + java.time.Duration connect = defaultDuration(properties.getConnectTimeout(), 10); + java.time.Duration read = defaultDuration(properties.getReadTimeout(), 60); + httpClient = new OkHttpClient.Builder() + .connectTimeout(connect.toMillis(), TimeUnit.MILLISECONDS) + .readTimeout(read.toMillis(), TimeUnit.MILLISECONDS) + .build(); + } + } + } + return httpClient; + } + + private Duration defaultDuration(Duration duration, long seconds) { + return duration == null ? Duration.ofSeconds(seconds) : duration; + } + + private ServiceException buildException(String body) { + try { + JsonNode root = objectMapper.readTree(body); + String message = CollUtil.getFirst( + CollUtil.newArrayList( + root.path("message").asText(null), + root.path("output").path("message").asText(null))); + return exception0(VOICE_TTS_FAILED.getCode(), StrUtil.blankToDefault(message, "CosyVoice 调用失败")); + } catch (Exception ignored) { + return exception0(VOICE_TTS_FAILED.getCode(), body); + } + } +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/LatentsyncClient.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/LatentsyncClient.java new file mode 100644 index 0000000000..56b15914c7 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/LatentsyncClient.java @@ -0,0 +1,141 @@ +package cn.iocoder.yudao.module.tik.voice.client; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.module.tik.voice.client.dto.LatentsyncSubmitRequest; +import cn.iocoder.yudao.module.tik.voice.client.dto.LatentsyncSubmitResponse; +import cn.iocoder.yudao.module.tik.voice.config.LatentsyncProperties; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception0; +import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.LATENTSYNC_SUBMIT_FAILED; + +/** + * 302AI Latentsync 客户端 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class LatentsyncClient { + + private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + + private final LatentsyncProperties properties; + private final ObjectMapper objectMapper; + + private volatile OkHttpClient httpClient; + + public LatentsyncSubmitResponse submitTask(LatentsyncSubmitRequest request) { + if (!properties.isEnabled()) { + throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "未配置 Latentsync API Key"); + } + validateRequest(request); + + Map payload = buildPayload(request); + try { + String body = objectMapper.writeValueAsString(payload); + Request httpRequest = new Request.Builder() + .url(properties.getSubmitUrl()) + .addHeader("Authorization", "Bearer " + properties.getApiKey()) + .addHeader("Content-Type", "application/json") + .post(RequestBody.create(body.getBytes(StandardCharsets.UTF_8), JSON)) + .build(); + + try (Response response = getHttpClient().newCall(httpRequest).execute()) { + String responseBody = response.body() != null ? response.body().string() : ""; + if (!response.isSuccessful()) { + log.error("[Latentsync][submit failed][status={}, body={}]", response.code(), responseBody); + throw buildException(responseBody); + } + LatentsyncSubmitResponse submitResponse = + objectMapper.readValue(responseBody, LatentsyncSubmitResponse.class); + if (StrUtil.isBlank(submitResponse.getRequestId())) { + log.error("[Latentsync][submit failed][response={}]", responseBody); + throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "Latentsync 返回 requestId 为空"); + } + return submitResponse; + } + } catch (ServiceException ex) { + throw ex; + } catch (Exception ex) { + log.error("[Latentsync][submit exception]", ex); + throw exception(LATENTSYNC_SUBMIT_FAILED); + } + } + + private void validateRequest(LatentsyncSubmitRequest request) { + if (request == null) { + throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "请求体不能为空"); + } + if (StrUtil.isBlank(request.getAudioUrl())) { + throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "音频地址不能为空"); + } + if (StrUtil.isBlank(request.getVideoUrl())) { + throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "视频地址不能为空"); + } + Integer scale = request.getGuidanceScale(); + if (scale != null && (scale < 1 || scale > 2)) { + throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "guidanceScale 取值范围 1-2"); + } + } + + private Map buildPayload(LatentsyncSubmitRequest request) { + Map payload = new HashMap<>(); + payload.put("audio_url", request.getAudioUrl()); + payload.put("video_url", request.getVideoUrl()); + Integer scale = request.getGuidanceScale() != null + ? request.getGuidanceScale() : properties.getDefaultGuidanceScale(); + payload.put("guidance_scale", scale); + Integer seed = request.getSeed() != null ? request.getSeed() : properties.getDefaultSeed(); + payload.put("seed", seed); + return payload; + } + + private OkHttpClient getHttpClient() { + if (httpClient == null) { + synchronized (this) { + if (httpClient == null) { + Duration connect = defaultDuration(properties.getConnectTimeout(), 10); + Duration read = defaultDuration(properties.getReadTimeout(), 60); + httpClient = new OkHttpClient.Builder() + .connectTimeout(connect.toMillis(), TimeUnit.MILLISECONDS) + .readTimeout(read.toMillis(), TimeUnit.MILLISECONDS) + .build(); + } + } + } + return httpClient; + } + + private Duration defaultDuration(Duration duration, long seconds) { + return duration == null ? Duration.ofSeconds(seconds) : duration; + } + + private ServiceException buildException(String body) { + try { + JsonNode root = objectMapper.readTree(body); + String message = root.path("message").asText(body); + return exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), message); + } catch (Exception ignored) { + return exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), body); + } + } +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/dto/CosyVoiceTtsRequest.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/dto/CosyVoiceTtsRequest.java new file mode 100644 index 0000000000..6fcc1f66b9 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/dto/CosyVoiceTtsRequest.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.tik.voice.client.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * CosyVoice TTS 请求 + */ +@Data +@Builder +public class CosyVoiceTtsRequest { + + /** + * 待合成文本 + */ + private String text; + + /** + * 声音 ID(可选,默认使用配置) + */ + private String voiceId; + + /** + * 模型(默认 cosyvoice-v2) + */ + private String model; + + /** + * 语速 + */ + private Float speechRate; + + /** + * 音量,可选 + */ + private Float volume; + + /** + * 采样率 + */ + private Integer sampleRate; + + /** + * 音频格式 + */ + private String audioFormat; + + /** + * 是否仅用于试听,方便服务侧做限流 + */ + private boolean preview; +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/dto/CosyVoiceTtsResult.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/dto/CosyVoiceTtsResult.java new file mode 100644 index 0000000000..3a100fff4a --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/dto/CosyVoiceTtsResult.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.tik.voice.client.dto; + +import lombok.Data; + +/** + * CosyVoice TTS 响应 + */ +@Data +public class CosyVoiceTtsResult { + + /** + * 请求ID + */ + private String requestId; + + /** + * 返回的音频格式 + */ + private String format; + + /** + * 采样率 + */ + private Integer sampleRate; + + /** + * 音频二进制内容 + */ + private byte[] audio; + + /** + * 音频所使用的 voiceId + */ + private String voiceId; +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/dto/LatentsyncSubmitRequest.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/dto/LatentsyncSubmitRequest.java new file mode 100644 index 0000000000..f8eb5db999 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/dto/LatentsyncSubmitRequest.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.tik.voice.client.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * Latentsync 任务提交请求 + */ +@Data +@Builder +public class LatentsyncSubmitRequest { + + /** + * 音频地址(必填) + */ + private String audioUrl; + + /** + * 视频地址(必填) + */ + private String videoUrl; + + /** + * 口型约束力度(1-2) + */ + private Integer guidanceScale; + + /** + * 随机种子 + */ + private Integer seed; +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/dto/LatentsyncSubmitResponse.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/dto/LatentsyncSubmitResponse.java new file mode 100644 index 0000000000..c749aef99e --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/dto/LatentsyncSubmitResponse.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.tik.voice.client.dto; + +import lombok.Data; + +import java.util.Map; + +/** + * Latentsync 任务提交响应 + */ +@Data +public class LatentsyncSubmitResponse { + + /** + * 日志内容(官方暂未返回,预留) + */ + private Object logs; + + /** + * 指标信息 + */ + private Map metrics; + + /** + * 队列位置 + */ + private Integer queuePosition; + + /** + * 任务 ID + */ + private String requestId; + + /** + * 当前状态 + */ + private String status; +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/config/CosyVoiceProperties.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/config/CosyVoiceProperties.java new file mode 100644 index 0000000000..60b39c4abb --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/config/CosyVoiceProperties.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.tik.voice.config; + +import cn.hutool.core.util.StrUtil; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * CosyVoice 配置 + */ +@Data +@Component +@ConfigurationProperties(prefix = "yudao.cosyvoice") +public class CosyVoiceProperties { + + /** + * DashScope API Key + */ + private String apiKey; + + /** + * 默认模型 + */ + private String defaultModel = "cosyvoice-v2"; + + /** + * 默认 voiceId(可选) + */ + private String defaultVoiceId; + + /** + * 默认采样率 + */ + private Integer sampleRate = 24000; + + /** + * 默认音频格式 + */ + private String audioFormat = "wav"; + + /** + * 试听默认示例文本 + */ + private String previewText = "您好,欢迎体验专属音色。"; + + /** + * TTS 接口地址 + */ + private String ttsUrl = "https://dashscope.aliyuncs.com/api/v1/services/audio/tts/speech-synthesis"; + + /** + * 连接超时时间 + */ + private Duration connectTimeout = Duration.ofSeconds(10); + + /** + * 读取超时时间 + */ + private Duration readTimeout = Duration.ofSeconds(60); + + /** + * 是否启用 + */ + private boolean enabled = true; + + public boolean isEnabled() { + return enabled && StrUtil.isNotBlank(apiKey); + } + +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/config/LatentsyncProperties.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/config/LatentsyncProperties.java new file mode 100644 index 0000000000..b9e8d3b099 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/config/LatentsyncProperties.java @@ -0,0 +1,78 @@ +package cn.iocoder.yudao.module.tik.voice.config; + +import cn.hutool.core.util.StrUtil; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * Latentsync 接口配置 + */ +@Data +@Component +@ConfigurationProperties(prefix = "tik.latentsync") +public class LatentsyncProperties { + + /** + * 302AI API Key(可通过配置覆盖) + */ + private String apiKey = "ab900d8c94094a90aed3e88cdba785c1"; + + /** + * 默认海外网关 + */ + private String baseUrl = "https://api.302.ai"; + + /** + * 默认国内中转网关 + */ + private String domesticBaseUrl = "https://api.302ai.cn"; + + /** + * 是否优先使用国内网关 + */ + private boolean preferDomestic = false; + + /** + * 提交任务路径 + */ + private String submitPath = "/302/submit/latentsync"; + + /** + * guidance_scale 默认值(1-2) + */ + private Integer defaultGuidanceScale = 1; + + /** + * 随机种子默认值 + */ + private Integer defaultSeed = 8888; + + /** + * 连接超时时间 + */ + private Duration connectTimeout = Duration.ofSeconds(10); + + /** + * 读取超时时间 + */ + private Duration readTimeout = Duration.ofSeconds(60); + + /** + * 是否打开调用 + */ + private boolean enabled = true; + + public String getSubmitUrl() { + String base = preferDomestic ? domesticBaseUrl : baseUrl; + return StrUtil.blankToDefault(base, baseUrl) + submitPath; + } + + public boolean isEnabled() { + return enabled && StrUtil.isNotBlank(apiKey); + } +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/controller/AppTikLatentsyncController.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/controller/AppTikLatentsyncController.java new file mode 100644 index 0000000000..6e432f6284 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/controller/AppTikLatentsyncController.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.tik.voice.controller; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.tik.voice.service.LatentsyncService; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikLatentsyncSubmitReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikLatentsyncSubmitRespVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 用户 App - Latentsync 口型同步 + */ +@Tag(name = "用户 App - Latentsync 口型同步") +@RestController +@RequestMapping("/api/tik/latentsync") +@Validated +public class AppTikLatentsyncController { + + @Resource + private LatentsyncService latentsyncService; + + @PostMapping("/submit") + @Operation(summary = "提交 302AI Latentsync 口型任务") + public CommonResult submitTask(@Valid @RequestBody AppTikLatentsyncSubmitReqVO reqVO) { + return success(latentsyncService.submitTask(reqVO)); + } +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/controller/AppTikUserVoiceController.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/controller/AppTikUserVoiceController.java new file mode 100644 index 0000000000..45b5532806 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/controller/AppTikUserVoiceController.java @@ -0,0 +1,95 @@ +package cn.iocoder.yudao.module.tik.voice.controller; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.tik.voice.service.TikUserVoiceService; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikUserVoiceCreateReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikUserVoicePageReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikUserVoiceRespVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikUserVoiceUpdateReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikVoicePreviewReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikVoicePreviewRespVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikVoiceTtsReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikVoiceTtsRespVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 用户 App - 配音管理 Controller + * + * @author 芋道源码 + */ +@Tag(name = "用户 App - 配音管理") +@RestController +@RequestMapping("/api/tik/voice") +@Validated +@Slf4j +public class AppTikUserVoiceController { + + @Resource + private TikUserVoiceService voiceService; + + @PostMapping("/create") + @Operation(summary = "创建配音") + public CommonResult createVoice(@Valid @RequestBody AppTikUserVoiceCreateReqVO createReqVO) { + return success(voiceService.createVoice(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新配音") + public CommonResult updateVoice(@Valid @RequestBody AppTikUserVoiceUpdateReqVO updateReqVO) { + voiceService.updateVoice(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除配音") + @Parameter(name = "id", description = "配音编号", required = true, example = "1") + public CommonResult deleteVoice(@RequestParam("id") Long id) { + voiceService.deleteVoice(id); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "分页查询配音列表") + public CommonResult> getVoicePage(@Valid AppTikUserVoicePageReqVO pageReqVO) { + return success(voiceService.getVoicePage(pageReqVO)); + } + + @GetMapping("/get") + @Operation(summary = "获取单个配音") + @Parameter(name = "id", description = "配音编号", required = true, example = "1") + public CommonResult getVoice(@RequestParam("id") Long id) { + return success(voiceService.getVoice(id)); + } + + @PostMapping("/transcribe") + @Operation(summary = "手动触发语音识别") + @Parameter(name = "id", description = "配音编号", required = true, example = "1") + public CommonResult transcribeVoice(@RequestParam("id") Long id) { + voiceService.transcribeVoice(id); + return success(true); + } + + @PostMapping("/tts") + @Operation(summary = "CosyVoice 文本转语音") + public CommonResult synthesizeVoice(@Valid @RequestBody AppTikVoiceTtsReqVO reqVO) { + return success(voiceService.synthesizeVoice(reqVO)); + } + + @PostMapping("/preview") + @Operation(summary = "我的音色试听") + public CommonResult previewVoice(@Valid @RequestBody AppTikVoicePreviewReqVO reqVO) { + return success(voiceService.previewVoice(reqVO)); + } + +} + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/dataobject/TikUserVoiceDO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/dataobject/TikUserVoiceDO.java new file mode 100644 index 0000000000..4ab397b217 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/dataobject/TikUserVoiceDO.java @@ -0,0 +1,59 @@ +package cn.iocoder.yudao.module.tik.voice.dal.dataobject; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 用户配音 DO + * + * @author 芋道源码 + */ +@TableName("tik_user_voice") +@KeySequence("tik_user_voice_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TikUserVoiceDO extends TenantBaseDO { + + /** + * 配音编号 + */ + @TableId + private Long id; + /** + * 用户编号 + */ + private Long userId; + /** + * 配音名称 + */ + private String name; + /** + * 音频文件编号(关联 infra_file.id) + */ + private Long fileId; + /** + * 语音识别内容,为空表示未识别,有值表示已识别 + */ + private String transcription; + /** + * 语言:zh-CN-简体中文,zh-TW-繁體中文,en-US-English + */ + private String language; + /** + * 音色类型:female-女声,male-男声 + */ + private String gender; + /** + * 备注信息 + */ + private String note; + +} + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/mysql/TikUserVoiceMapper.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/mysql/TikUserVoiceMapper.java new file mode 100644 index 0000000000..ae709b0b56 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/mysql/TikUserVoiceMapper.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.tik.voice.dal.mysql; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikUserVoiceDO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikUserVoicePageReqVO; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户配音 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface TikUserVoiceMapper extends BaseMapperX { + + default PageResult selectPage(AppTikUserVoicePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(TikUserVoiceDO::getUserId, reqVO.getUserId()) + .likeIfPresent(TikUserVoiceDO::getName, reqVO.getName()) + .orderByDesc(TikUserVoiceDO::getId)); + } + +} + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncService.java new file mode 100644 index 0000000000..4c14ff0801 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncService.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.tik.voice.service; + +import cn.iocoder.yudao.module.tik.voice.vo.AppTikLatentsyncSubmitReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikLatentsyncSubmitRespVO; + +/** + * Latentsync 口型同步 Service + */ +public interface LatentsyncService { + + /** + * 提交 302AI Latentsync 任务 + * + * @param reqVO 请求 VO + * @return 任务响应 + */ + AppTikLatentsyncSubmitRespVO submitTask(AppTikLatentsyncSubmitReqVO reqVO); +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncServiceImpl.java new file mode 100644 index 0000000000..152956906a --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncServiceImpl.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.tik.voice.service; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.tik.voice.client.LatentsyncClient; +import cn.iocoder.yudao.module.tik.voice.client.dto.LatentsyncSubmitRequest; +import cn.iocoder.yudao.module.tik.voice.client.dto.LatentsyncSubmitResponse; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikLatentsyncSubmitReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikLatentsyncSubmitRespVO; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +/** + * Latentsync Service 实现 + */ +@Service +@Validated +@RequiredArgsConstructor +public class LatentsyncServiceImpl implements LatentsyncService { + + private final LatentsyncClient latentsyncClient; + + @Override + public AppTikLatentsyncSubmitRespVO submitTask(@Valid AppTikLatentsyncSubmitReqVO reqVO) { + LatentsyncSubmitRequest request = LatentsyncSubmitRequest.builder() + .audioUrl(StrUtil.trim(reqVO.getAudioUrl())) + .videoUrl(StrUtil.trim(reqVO.getVideoUrl())) + .guidanceScale(reqVO.getGuidanceScale()) + .seed(reqVO.getSeed()) + .build(); + + LatentsyncSubmitResponse response = latentsyncClient.submitTask(request); + AppTikLatentsyncSubmitRespVO respVO = new AppTikLatentsyncSubmitRespVO(); + respVO.setRequestId(response.getRequestId()); + respVO.setStatus(response.getStatus()); + respVO.setQueuePosition(response.getQueuePosition()); + return respVO; + } +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/TikUserVoiceService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/TikUserVoiceService.java new file mode 100644 index 0000000000..c6ebaf5bbf --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/TikUserVoiceService.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.tik.voice.service; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikUserVoiceCreateReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikUserVoicePageReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikUserVoiceRespVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikUserVoiceUpdateReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikVoicePreviewReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikVoicePreviewRespVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikVoiceTtsReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikVoiceTtsRespVO; + +/** + * 用户配音 Service 接口 + * + * @author 芋道源码 + */ +public interface TikUserVoiceService { + + /** + * 创建配音(上传文件 + 可选自动识别) + * + * @param createReqVO 创建请求 VO + * @return 配音编号 + */ + Long createVoice(AppTikUserVoiceCreateReqVO createReqVO); + + /** + * 更新配音信息 + * + * @param updateReqVO 更新请求 VO + */ + void updateVoice(AppTikUserVoiceUpdateReqVO updateReqVO); + + /** + * 删除配音 + * + * @param id 配音编号 + */ + void deleteVoice(Long id); + + /** + * 分页查询 + * + * @param pageReqVO 分页查询条件 + * @return 配音列表 + */ + PageResult getVoicePage(AppTikUserVoicePageReqVO pageReqVO); + + /** + * 获取单个配音 + * + * @param id 配音编号 + * @return 配音信息 + */ + AppTikUserVoiceRespVO getVoice(Long id); + + /** + * 手动触发语音识别 + * + * @param id 配音编号 + */ + void transcribeVoice(Long id); + + /** + * CosyVoice 文本转语音 + */ + AppTikVoiceTtsRespVO synthesizeVoice(AppTikVoiceTtsReqVO reqVO); + + /** + * 我的音色试听 + */ + AppTikVoicePreviewRespVO previewVoice(AppTikVoicePreviewReqVO reqVO); +} + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/TikUserVoiceServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/TikUserVoiceServiceImpl.java new file mode 100644 index 0000000000..faf6058ce8 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/TikUserVoiceServiceImpl.java @@ -0,0 +1,864 @@ +package cn.iocoder.yudao.module.tik.voice.service; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpUtil; +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +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.tik.file.dal.dataobject.TikUserFileDO; +import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileMapper; +import cn.iocoder.yudao.module.tik.file.service.TikUserFileService; +import cn.iocoder.yudao.module.tik.tikhup.service.TikHupService; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.tik.voice.client.CosyVoiceClient; +import cn.iocoder.yudao.module.tik.voice.client.dto.CosyVoiceTtsRequest; +import cn.iocoder.yudao.module.tik.voice.client.dto.CosyVoiceTtsResult; +import cn.iocoder.yudao.module.tik.voice.config.CosyVoiceProperties; +import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikUserVoiceDO; +import cn.iocoder.yudao.module.tik.voice.dal.mysql.TikUserVoiceMapper; +import cn.iocoder.yudao.module.tik.voice.util.ByteArrayMultipartFile; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikUserVoiceCreateReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikUserVoicePageReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikUserVoiceRespVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikUserVoiceUpdateReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikVoicePreviewReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikVoicePreviewRespVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikVoiceTtsReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikVoiceTtsRespVO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import jakarta.annotation.Resource; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.*; + +/** + * 用户配音 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class TikUserVoiceServiceImpl implements TikUserVoiceService { + + @Resource + private TikUserVoiceMapper voiceMapper; + + @Resource + private FileMapper fileMapper; + + @Resource + private TikUserFileMapper userFileMapper; + + @Resource + private TikUserFileService tikUserFileService; + + @Resource + private FileApi fileApi; + + @Resource + private TikHupService tikHupService; + + @Resource + private CosyVoiceClient cosyVoiceClient; + + @Resource + private CosyVoiceProperties cosyVoiceProperties; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** 预签名URL过期时间(1小时,单位:秒) */ + private static final int PRESIGN_URL_EXPIRATION_SECONDS = 3600; + private static final String PREVIEW_CACHE_PREFIX = "tik:voice:preview:"; + private static final String SYNTH_CACHE_PREFIX = "tik:voice:tts:"; + private static final long PREVIEW_CACHE_TTL_SECONDS = 3600; + private static final long SYNTH_CACHE_TTL_SECONDS = 24 * 3600; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createVoice(AppTikUserVoiceCreateReqVO createReqVO) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + + // 1. 校验文件是否存在且属于voice分类 + FileDO fileDO = fileMapper.selectById(createReqVO.getFileId()); + if (fileDO == null) { + throw exception(VOICE_FILE_NOT_EXISTS); + } + + // 验证文件分类是否为voice(通过tik_user_file表查询) + TikUserFileDO userFile = userFileMapper.selectOne(new LambdaQueryWrapperX() + .eq(TikUserFileDO::getFileId, createReqVO.getFileId()) + .eq(TikUserFileDO::getFileCategory, "voice") + .eq(TikUserFileDO::getUserId, userId)); + if (userFile == null) { + throw exception(VOICE_FILE_NOT_EXISTS, "文件不存在或不属于voice分类"); + } + + // 2. 校验名称是否重复 + TikUserVoiceDO existingVoice = voiceMapper.selectOne(new LambdaQueryWrapperX() + .eq(TikUserVoiceDO::getUserId, userId) + .eq(TikUserVoiceDO::getName, createReqVO.getName()) + .eq(TikUserVoiceDO::getDeleted, false)); + if (existingVoice != null) { + throw exception(VOICE_NAME_DUPLICATE); + } + + // 3. 创建配音记录 + TikUserVoiceDO voice = new TikUserVoiceDO() + .setUserId(userId) + .setName(createReqVO.getName()) + .setFileId(createReqVO.getFileId()) + .setLanguage(StrUtil.blankToDefault(createReqVO.getLanguage(), "zh-CN")) + .setGender(StrUtil.blankToDefault(createReqVO.getGender(), "female")) + .setNote(createReqVO.getNote()) + .setTranscription(null); // 初始为空,表示未识别 + voiceMapper.insert(voice); + + // 4. 如果开启自动识别,异步执行识别 + if (Boolean.TRUE.equals(createReqVO.getAutoTranscribe())) { + String fileAccessUrl = fileApi.presignGetUrl(fileDO.getUrl(), PRESIGN_URL_EXPIRATION_SECONDS); + log.info("[createVoice][开启自动识别,配音编号({}),文件ID({}),预签名URL({})]", + voice.getId(), fileDO.getId(), fileAccessUrl); + asyncTranscribeVoice(voice.getId(), fileAccessUrl); + } + + log.info("[createVoice][用户({})创建配音成功,配音编号({})]", userId, voice.getId()); + return voice.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateVoice(AppTikUserVoiceUpdateReqVO updateReqVO) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + + // 1. 校验配音是否存在且属于当前用户 + TikUserVoiceDO voice = voiceMapper.selectById(updateReqVO.getId()); + if (voice == null || !voice.getUserId().equals(userId)) { + throw exception(VOICE_NOT_EXISTS); + } + + // 2. 如果更新名称,校验名称是否重复 + if (StrUtil.isNotBlank(updateReqVO.getName()) && !updateReqVO.getName().equals(voice.getName())) { + TikUserVoiceDO existingVoice = voiceMapper.selectOne(new LambdaQueryWrapperX() + .eq(TikUserVoiceDO::getUserId, userId) + .eq(TikUserVoiceDO::getName, updateReqVO.getName()) + .eq(TikUserVoiceDO::getDeleted, false) + .ne(TikUserVoiceDO::getId, updateReqVO.getId())); + if (existingVoice != null) { + throw exception(VOICE_NAME_DUPLICATE); + } + } + + // 3. 更新配音信息 + TikUserVoiceDO updateObj = new TikUserVoiceDO() + .setId(updateReqVO.getId()); + if (StrUtil.isNotBlank(updateReqVO.getName())) { + updateObj.setName(updateReqVO.getName()); + } + if (StrUtil.isNotBlank(updateReqVO.getLanguage())) { + updateObj.setLanguage(updateReqVO.getLanguage()); + } + if (StrUtil.isNotBlank(updateReqVO.getGender())) { + updateObj.setGender(updateReqVO.getGender()); + } + if (updateReqVO.getNote() != null) { + updateObj.setNote(updateReqVO.getNote()); + } + if (updateReqVO.getTranscription() != null) { + updateObj.setTranscription(updateReqVO.getTranscription()); + } + voiceMapper.updateById(updateObj); + + log.info("[updateVoice][用户({})更新配音成功,配音编号({})]", userId, updateReqVO.getId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteVoice(Long id) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + + // 1. 校验配音是否存在且属于当前用户 + TikUserVoiceDO voice = voiceMapper.selectById(id); + if (voice == null || !voice.getUserId().equals(userId)) { + throw exception(VOICE_NOT_EXISTS); + } + + // 2. 删除音频文件(含OSS) + TikUserFileDO userFile = userFileMapper.selectOne(new LambdaQueryWrapperX() + .eq(TikUserFileDO::getFileId, voice.getFileId()) + .eq(TikUserFileDO::getUserId, userId)); + if (userFile != null) { + tikUserFileService.deleteFiles(Collections.singletonList(userFile.getId())); + } + + // 3. 逻辑删除配音记录 + voiceMapper.deleteById(id); + + log.info("[deleteVoice][用户({})删除配音成功,配音编号({})]", userId, id); + } + + @Override + public PageResult getVoicePage(AppTikUserVoicePageReqVO pageReqVO) { + // 自动填充当前登录用户ID + Long userId = SecurityFrameworkUtils.getLoginUserId(); + pageReqVO.setUserId(userId); + + // 查询配音列表 + PageResult pageResult = voiceMapper.selectPage(pageReqVO); + + // 批量查询文件信息,避免 N+1 查询 + Map fileMap = new HashMap<>(); + if (CollUtil.isNotEmpty(pageResult.getList())) { + List fileIds = pageResult.getList().stream() + .map(TikUserVoiceDO::getFileId) + .distinct() + .collect(Collectors.toList()); + + if (CollUtil.isNotEmpty(fileIds)) { + List files = fileMapper.selectBatchIds(fileIds); + Map tempFileMap = files.stream() + .collect(Collectors.toMap(FileDO::getId, file -> file)); + fileMap.putAll(tempFileMap); + } + } + + // 转换为VO并关联查询文件信息 + return CollectionUtils.convertPage(pageResult, voice -> { + AppTikUserVoiceRespVO vo = BeanUtils.toBean(voice, AppTikUserVoiceRespVO.class); + + // 通过 file_id 关联查询文件URL,并生成预签名URL + FileDO fileDO = fileMap.get(voice.getFileId()); + if (fileDO != null) { + // 生成预签名URL(1小时有效期) + String presignedUrl = fileApi.presignGetUrl(fileDO.getUrl(), PRESIGN_URL_EXPIRATION_SECONDS); + vo.setFileUrl(presignedUrl); + } + + return vo; + }); + } + + @Override + public AppTikUserVoiceRespVO getVoice(Long id) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + + // 1. 查询配音 + TikUserVoiceDO voice = voiceMapper.selectById(id); + if (voice == null || !voice.getUserId().equals(userId)) { + throw exception(VOICE_NOT_EXISTS); + } + + // 2. 转换为VO并关联查询文件信息 + AppTikUserVoiceRespVO vo = BeanUtils.toBean(voice, AppTikUserVoiceRespVO.class); + + // 通过 file_id 关联查询文件URL,并生成预签名URL + FileDO fileDO = fileMapper.selectById(voice.getFileId()); + if (fileDO != null) { + // 生成预签名URL(1小时有效期) + String presignedUrl = fileApi.presignGetUrl(fileDO.getUrl(), PRESIGN_URL_EXPIRATION_SECONDS); + vo.setFileUrl(presignedUrl); + } + + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void transcribeVoice(Long id) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + + // 1. 校验配音是否存在且属于当前用户 + TikUserVoiceDO voice = voiceMapper.selectById(id); + if (voice == null || !voice.getUserId().equals(userId)) { + throw exception(VOICE_NOT_EXISTS); + } + + // 2. 获取文件URL + FileDO fileDO = fileMapper.selectById(voice.getFileId()); + if (fileDO == null) { + throw exception(VOICE_FILE_NOT_EXISTS); + } + + // 3. 异步执行识别 + String fileAccessUrl = fileApi.presignGetUrl(fileDO.getUrl(), PRESIGN_URL_EXPIRATION_SECONDS); + asyncTranscribeVoice(id, fileAccessUrl); + } + + @Override + public AppTikVoiceTtsRespVO synthesizeVoice(AppTikVoiceTtsReqVO reqVO) { + String finalText = determineSynthesisText( + reqVO.getTranscriptionText(), + reqVO.getInputText(), + false); + finalText = appendEmotion(finalText, reqVO.getEmotion()); + + String cacheKey = buildCacheKey(SYNTH_CACHE_PREFIX, + reqVO.getVoiceId(), + reqVO.getFileUrl(), + finalText, + reqVO.getSpeechRate(), + reqVO.getVolume(), + reqVO.getEmotion(), + reqVO.getAudioFormat(), + reqVO.getSampleRate()); + + SynthCacheEntry synthCache = getSynthCache(cacheKey); + if (synthCache != null) { + return buildSynthResponseFromCache(reqVO, synthCache); + } + + CosyVoiceTtsResult ttsResult = cosyVoiceClient.synthesize(buildTtsRequest( + finalText, + reqVO.getVoiceId(), + reqVO.getModel(), + reqVO.getSpeechRate(), + reqVO.getVolume(), + reqVO.getSampleRate(), + reqVO.getAudioFormat(), + false + )); + + String format = defaultFormat(ttsResult.getFormat(), reqVO.getAudioFormat()); + String voiceId = StrUtil.blankToDefault(reqVO.getVoiceId(), cosyVoiceProperties.getDefaultVoiceId()); + ByteArrayMultipartFile multipartFile = new ByteArrayMultipartFile( + "file", + buildFileName(voiceId, format), + resolveContentType(format), + ttsResult.getAudio() + ); + Long fileId = tikUserFileService.uploadFile(multipartFile, "audio", null); + + AppTikVoiceTtsRespVO respVO = new AppTikVoiceTtsRespVO(); + respVO.setFileId(fileId); + respVO.setAudioUrl(tikUserFileService.getAudioPlayUrl(fileId)); + respVO.setFormat(format); + respVO.setSampleRate(ttsResult.getSampleRate()); + respVO.setRequestId(ttsResult.getRequestId()); + respVO.setVoiceId(voiceId); + + saveSynthCache(cacheKey, new SynthCacheEntry( + Base64.getEncoder().encodeToString(ttsResult.getAudio()), + format, + ttsResult.getSampleRate(), + ttsResult.getRequestId(), + voiceId + )); + return respVO; + } + + @Override + public AppTikVoicePreviewRespVO previewVoice(AppTikVoicePreviewReqVO reqVO) { + String finalText = determineSynthesisText( + reqVO.getTranscriptionText(), + reqVO.getInputText(), + true); + finalText = appendEmotion(finalText, reqVO.getEmotion()); + + String cacheKey = buildCacheKey(PREVIEW_CACHE_PREFIX, + reqVO.getVoiceId(), + reqVO.getFileUrl(), + finalText, + reqVO.getSpeechRate(), + reqVO.getVolume(), + reqVO.getEmotion(), + reqVO.getAudioFormat(), + null); + PreviewCacheEntry previewCache = getPreviewCache(cacheKey); + String voiceId = StrUtil.blankToDefault(reqVO.getVoiceId(), cosyVoiceProperties.getDefaultVoiceId()); + + if (previewCache != null) { + String cachedUrl = fileApi.presignGetUrl(previewCache.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS); + return buildPreviewResp(previewCache, cachedUrl, voiceId); + } + + CosyVoiceTtsResult ttsResult = cosyVoiceClient.synthesize(buildTtsRequest( + finalText, + reqVO.getVoiceId(), + reqVO.getModel(), + reqVO.getSpeechRate(), + reqVO.getVolume(), + null, + reqVO.getAudioFormat(), + true + )); + + String format = defaultFormat(ttsResult.getFormat(), reqVO.getAudioFormat()); + voiceId = StrUtil.blankToDefault(reqVO.getVoiceId(), cosyVoiceProperties.getDefaultVoiceId()); + String objectName = buildFileName(voiceId, format); + String fileUrl = fileApi.createFile(ttsResult.getAudio(), objectName, "voice/preview", resolveContentType(format)); + String presignUrl = fileApi.presignGetUrl(fileUrl, PRESIGN_URL_EXPIRATION_SECONDS); + + PreviewCacheEntry entry = new PreviewCacheEntry(fileUrl, format, ttsResult.getSampleRate(), ttsResult.getRequestId()); + savePreviewCache(cacheKey, entry); + return buildPreviewResp(entry, presignUrl, voiceId); + } + + private CosyVoiceTtsRequest buildTtsRequest(String text, + String voiceId, + String model, + Float speechRate, + Float volume, + Integer sampleRate, + String audioFormat, + boolean preview) { + return CosyVoiceTtsRequest.builder() + .text(text) + .voiceId(voiceId) + .model(model) + .speechRate(speechRate) + .volume(volume) + .sampleRate(sampleRate) + .audioFormat(audioFormat) + .preview(preview) + .build(); + } + + private String defaultFormat(String responseFormat, String requestFormat) { + return StrUtil.blankToDefault(responseFormat, + StrUtil.blankToDefault(requestFormat, cosyVoiceProperties.getAudioFormat())); + } + + private String buildFileName(String voiceId, String format) { + String safeVoice = StrUtil.blankToDefault(voiceId, "voice") + .replaceAll("[^a-zA-Z0-9_-]", ""); + return safeVoice + "-" + System.currentTimeMillis() + "." + format; + } + + private String resolveContentType(String format) { + if ("wav".equalsIgnoreCase(format)) { + return "audio/wav"; + } + if ("mp3".equalsIgnoreCase(format)) { + return "audio/mpeg"; + } + if ("flac".equalsIgnoreCase(format)) { + return "audio/flac"; + } + return "audio/mpeg"; + } + + private String determineSynthesisText(String transcriptionText, String inputText, boolean allowFallback) { + StringBuilder builder = new StringBuilder(); + if (StrUtil.isNotBlank(transcriptionText)) { + builder.append(transcriptionText.trim()); + } + if (StrUtil.isNotBlank(inputText)) { + if (builder.length() > 0) { + builder.append("\n"); + } + builder.append(inputText.trim()); + } + if (builder.length() > 0) { + return builder.toString(); + } + if (allowFallback) { + return cosyVoiceProperties.getPreviewText(); + } + throw exception(VOICE_TTS_FAILED, "请提供需要合成的文本内容"); + } + + private String appendEmotion(String text, String emotion) { + if (StrUtil.isBlank(text)) { + return text; + } + if (StrUtil.isBlank(emotion) || "neutral".equalsIgnoreCase(emotion)) { + return text; + } + String emotionLabel = switch (emotion.toLowerCase()) { + case "happy" -> "高兴"; + case "angry" -> "愤怒"; + case "sad" -> "悲伤"; + case "scared" -> "害怕"; + case "disgusted" -> "厌恶"; + case "surprised" -> "惊讶"; + default -> emotion; + }; + return "【情感:" + emotionLabel + "】" + text; + } + + private String buildCacheKey(String prefix, + String voiceId, + String fileUrl, + String text, + Float speechRate, + Float volume, + String emotion, + String audioFormat, + Integer sampleRate) { + String identifier = StrUtil.isNotBlank(voiceId) + ? voiceId + : StrUtil.blankToDefault(fileUrl, "no-voice"); + String payload = StrUtil.join("|", + identifier, + text, + speechRate != null ? speechRate : "1.0", + volume != null ? volume : "0", + StrUtil.blankToDefault(emotion, "neutral"), + StrUtil.blankToDefault(audioFormat, cosyVoiceProperties.getAudioFormat()), + sampleRate != null ? sampleRate : cosyVoiceProperties.getSampleRate()); + String hash = cn.hutool.crypto.SecureUtil.sha256(payload); + return prefix + hash; + } + + private PreviewCacheEntry getPreviewCache(String key) { + try { + String json = stringRedisTemplate.opsForValue().get(key); + if (StrUtil.isBlank(json)) { + return null; + } + return JSONUtil.toBean(json, PreviewCacheEntry.class); + } catch (Exception ex) { + log.warn("[previewVoice][cache read failed][key={}]", key, ex); + return null; + } + } + + private void savePreviewCache(String key, PreviewCacheEntry entry) { + try { + stringRedisTemplate.opsForValue().set( + key, + JSONUtil.toJsonStr(entry), + PREVIEW_CACHE_TTL_SECONDS, + TimeUnit.SECONDS); + } catch (Exception ex) { + log.warn("[previewVoice][cache write failed][key={}]", key, ex); + } + } + + private SynthCacheEntry getSynthCache(String key) { + try { + String json = stringRedisTemplate.opsForValue().get(key); + if (StrUtil.isBlank(json)) { + return null; + } + return JSONUtil.toBean(json, SynthCacheEntry.class); + } catch (Exception ex) { + log.warn("[synthesizeVoice][cache read failed][key={}]", key, ex); + return null; + } + } + + private void saveSynthCache(String key, SynthCacheEntry entry) { + try { + stringRedisTemplate.opsForValue().set( + key, + JSONUtil.toJsonStr(entry), + SYNTH_CACHE_TTL_SECONDS, + TimeUnit.SECONDS); + } catch (Exception ex) { + log.warn("[synthesizeVoice][cache write failed][key={}]", key, ex); + } + } + + private AppTikVoiceTtsRespVO buildSynthResponseFromCache(AppTikVoiceTtsReqVO reqVO, SynthCacheEntry cache) { + byte[] audioBytes = Base64.getDecoder().decode(cache.getAudioBase64()); + String format = defaultFormat(cache.getFormat(), reqVO.getAudioFormat()); + String voiceId = StrUtil.blankToDefault(reqVO.getVoiceId(), cache.getVoiceId()); + ByteArrayMultipartFile multipartFile = new ByteArrayMultipartFile( + "file", + buildFileName(voiceId, format), + resolveContentType(format), + audioBytes + ); + Long fileId = tikUserFileService.uploadFile(multipartFile, "audio", null); + + AppTikVoiceTtsRespVO respVO = new AppTikVoiceTtsRespVO(); + respVO.setFileId(fileId); + respVO.setAudioUrl(tikUserFileService.getAudioPlayUrl(fileId)); + respVO.setFormat(format); + respVO.setSampleRate(cache.getSampleRate()); + respVO.setRequestId(cache.getRequestId()); + respVO.setVoiceId(voiceId); + return respVO; + } + + private AppTikVoicePreviewRespVO buildPreviewResp(PreviewCacheEntry entry, String presignUrl, String voiceId) { + AppTikVoicePreviewRespVO respVO = new AppTikVoicePreviewRespVO(); + respVO.setAudioUrl(presignUrl); + respVO.setFormat(entry.getFormat()); + respVO.setSampleRate(entry.getSampleRate()); + respVO.setRequestId(entry.getRequestId()); + respVO.setVoiceId(voiceId); + return respVO; + } + + private static class PreviewCacheEntry { + private String fileUrl; + private String format; + private Integer sampleRate; + private String requestId; + + public PreviewCacheEntry() {} + + public PreviewCacheEntry(String fileUrl, String format, Integer sampleRate, String requestId) { + this.fileUrl = fileUrl; + this.format = format; + this.sampleRate = sampleRate; + this.requestId = requestId; + } + + public String getFileUrl() { + return fileUrl; + } + + public String getFormat() { + return format; + } + + public Integer getSampleRate() { + return sampleRate; + } + + public String getRequestId() { + return requestId; + } + } + + private static class SynthCacheEntry { + private String audioBase64; + private String format; + private Integer sampleRate; + private String requestId; + private String voiceId; + + public SynthCacheEntry() {} + + public SynthCacheEntry(String audioBase64, String format, Integer sampleRate, String requestId, String voiceId) { + this.audioBase64 = audioBase64; + this.format = format; + this.sampleRate = sampleRate; + this.requestId = requestId; + this.voiceId = voiceId; + } + + public String getAudioBase64() { + return audioBase64; + } + + public String getFormat() { + return format; + } + + public Integer getSampleRate() { + return sampleRate; + } + + public String getRequestId() { + return requestId; + } + + public String getVoiceId() { + return voiceId; + } + } + + /** + * 异步执行语音识别 + * + * @param voiceId 配音编号 + * @param fileUrl 文件URL + */ + @Async + public void asyncTranscribeVoice(Long voiceId, String fileUrl) { + try { + log.info("[asyncTranscribeVoice][开始识别,配音编号({}),文件URL({})]", voiceId, fileUrl); + Object result = tikHupService.videoToCharacters2(Collections.singletonList(fileUrl)); + + // 解析识别结果 + String transcription = extractTranscription(result); + + if (StrUtil.isNotBlank(transcription)) { + // 更新识别结果 + TikUserVoiceDO updateObj = new TikUserVoiceDO() + .setId(voiceId) + .setTranscription(transcription); + voiceMapper.updateById(updateObj); + log.info("[asyncTranscribeVoice][识别成功,配音编号({}),文本长度({})]", voiceId, transcription.length()); + } else { + log.warn("[asyncTranscribeVoice][识别结果为空,配音编号({}),返回码({})]", + voiceId, result instanceof CommonResult ? ((CommonResult) result).getCode() : "未知"); + } + } catch (Exception e) { + log.error("[asyncTranscribeVoice][识别失败,配音编号({}),文件URL({})]", voiceId, fileUrl, e); + } + } + + /** + * 从识别结果中提取文字内容 + * 根据 TikHupService.videoToCharacters* 的实际返回格式进行解析 + * + * @param result 识别结果 + * @return 文字内容 + */ + private String extractTranscription(Object result) { + if (result == null) { + return null; + } + + try { + if (result instanceof CommonResult commonResult) { + if (!commonResult.isSuccess()) { + log.warn("[extractTranscription][识别失败,code({}),msg({})]", + commonResult.getCode(), commonResult.getMsg()); + return null; + } + Object data = commonResult.getData(); + if (data == null) { + return null; + } + String parsed = parseTranscriptionText(data); + if (StrUtil.isNotBlank(parsed)) { + return parsed; + } + return data.toString(); + } + + String parsed = parseTranscriptionText(result); + if (StrUtil.isNotBlank(parsed)) { + return parsed; + } + return result.toString(); + } catch (Exception e) { + log.warn("[extractTranscription][解析识别结果失败]", e); + return null; + } + } + + private static final List TRANSCRIPTION_TEXT_KEYS = + Arrays.asList("text", "sentence", "result", "content", "transcript", "output_text", "display_text"); + + private String parseTranscriptionText(Object rawData) { + if (rawData == null) { + return null; + } + String rawString = rawData instanceof String ? (String) rawData : JSONUtil.toJsonStr(rawData); + if (StrUtil.isBlank(rawString)) { + return null; + } + if (!JSONUtil.isTypeJSON(rawString)) { + return rawString; + } + try { + Object json = JSONUtil.parse(rawString); + String localText = extractTextFromJson(json); + if (StrUtil.isNotBlank(localText)) { + return localText; + } + if (json instanceof JSONObject jsonObject) { + JSONArray results = jsonObject.getJSONArray("results"); + if (CollUtil.isEmpty(results)) { + return null; + } + Object lastObj = results.get(results.size() - 1); + if (!(lastObj instanceof JSONObject lastResult)) { + return null; + } + String transcriptionUrl = lastResult.getStr("transcription_url"); + if (StrUtil.isBlank(transcriptionUrl)) { + return null; + } + StringBuilder builder = new StringBuilder(); + appendRemoteTranscription(builder, transcriptionUrl); + return builder.length() > 0 ? builder.toString().trim() : null; + } + } catch (Exception e) { + log.warn("[parseTranscriptionText][解析Paraformer结果失败]", e); + } + return rawString; + } + + private void appendRemoteTranscription(StringBuilder builder, String transcriptionUrl) { + if (StrUtil.isBlank(transcriptionUrl)) { + return; + } + String remoteContent = fetchRemoteTranscription(transcriptionUrl); + if (StrUtil.isBlank(remoteContent)) { + return; + } + String remoteText = extractTextFromJson(JSONUtil.parse(remoteContent)); + if (StrUtil.isNotBlank(remoteText)) { + appendLine(builder, remoteText); + } + } + + private String extractTextFromJson(Object json) { + if (json == null) { + return null; + } + StringBuilder builder = new StringBuilder(); + collectTranscriptionText(json, builder); + return builder.length() > 0 ? builder.toString().trim() : null; + } + + private String fetchRemoteTranscription(String url) { + try { + String body = HttpUtil.get(url); + if (StrUtil.isNotBlank(body)) { + return body; + } + } catch (Exception e) { + log.warn("[fetchRemoteTranscription][下载转写文本失败,url({})]", url, e); + } + return null; + } + + private void collectTranscriptionText(Object node, StringBuilder builder) { + if (node == null) { + return; + } + if (node instanceof JSONObject jsonObject) { + for (String key : jsonObject.keySet()) { + Object value = jsonObject.get(key); + if (value == null) { + continue; + } + if (value instanceof CharSequence && TRANSCRIPTION_TEXT_KEYS.contains(key)) { + appendLine(builder, value.toString()); + } else if (value instanceof JSONObject || value instanceof JSONArray) { + collectTranscriptionText(value, builder); + } + } + } else if (node instanceof JSONArray jsonArray) { + for (Object item : jsonArray) { + collectTranscriptionText(item, builder); + } + } + } + + private void appendLine(StringBuilder builder, String line) { + String normalized = StrUtil.trim(line); + if (StrUtil.isBlank(normalized)) { + return; + } + if (builder.length() > 0) { + builder.append('\n'); + } + builder.append(normalized); + } + +} + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/util/ByteArrayMultipartFile.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/util/ByteArrayMultipartFile.java new file mode 100644 index 0000000000..f74628dd1f --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/util/ByteArrayMultipartFile.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.tik.voice.util; + +import org.springframework.util.FileCopyUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +/** + * 仅用于在服务内部上传的内存文件 + */ +public class ByteArrayMultipartFile implements MultipartFile { + + private final String name; + private final String originalFilename; + private final String contentType; + private final byte[] content; + + public ByteArrayMultipartFile(String name, String originalFilename, String contentType, byte[] content) { + this.name = name; + this.originalFilename = originalFilename; + this.contentType = contentType; + this.content = content != null ? content : new byte[0]; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return content.length == 0; + } + + @Override + public long getSize() { + return content.length; + } + + @Override + public byte[] getBytes() { + return content; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(File dest) throws IOException { + FileCopyUtils.copy(content, dest); + } +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikLatentsyncSubmitReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikLatentsyncSubmitReqVO.java new file mode 100644 index 0000000000..f89c55ef56 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikLatentsyncSubmitReqVO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.tik.voice.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * Latentsync 提交请求 VO + */ +@Data +public class AppTikLatentsyncSubmitReqVO { + + @Schema(description = "音频 URL(需公网可访问)", requiredMode = Schema.RequiredMode.REQUIRED, + example = "https://example.com/audio.wav") + @NotBlank(message = "音频地址不能为空") + @Size(max = 1024, message = "音频地址长度不能超过 1024 字符") + private String audioUrl; + + @Schema(description = "视频 URL(需公网可访问)", requiredMode = Schema.RequiredMode.REQUIRED, + example = "https://example.com/video.mp4") + @NotBlank(message = "视频地址不能为空") + @Size(max = 1024, message = "视频地址长度不能超过 1024 字符") + private String videoUrl; + + @Schema(description = "guidance_scale,范围 1-2(默认 1)", example = "1") + @Min(value = 1, message = "guidanceScale 不能小于 1") + @Max(value = 2, message = "guidanceScale 不能大于 2") + private Integer guidanceScale; + + @Schema(description = "随机种子(默认 8888)", example = "8888") + private Integer seed; +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikLatentsyncSubmitRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikLatentsyncSubmitRespVO.java new file mode 100644 index 0000000000..1cc773afd3 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikLatentsyncSubmitRespVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.tik.voice.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * Latentsync 提交响应 VO + */ +@Data +public class AppTikLatentsyncSubmitRespVO { + + @Schema(description = "Latentsync 任务 ID", example = "8eed0b9b-6103-4357-a57b-9f135a8c3276") + private String requestId; + + @Schema(description = "官方状态,如 IN_QUEUE、PROCESSING、SUCCEEDED", example = "IN_QUEUE") + private String status; + + @Schema(description = "当前排队位置", example = "0") + private Integer queuePosition; +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoiceCreateReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoiceCreateReqVO.java new file mode 100644 index 0000000000..1aaae3eaa6 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoiceCreateReqVO.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.tik.voice.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 用户 App - 创建配音 Request VO + * + * @author 芋道源码 + */ +@Schema(description = "用户 App - 创建配音 Request VO") +@Data +public class AppTikUserVoiceCreateReqVO { + + @Schema(description = "配音名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "我的配音") + @NotBlank(message = "配音名称不能为空") + private String name; + + @Schema(description = "音频文件编号(关联 infra_file.id)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "音频文件编号不能为空") + private Long fileId; + + @Schema(description = "是否自动识别", example = "false") + private Boolean autoTranscribe; + + @Schema(description = "语言:zh-CN-简体中文,zh-TW-繁體中文,en-US-English", example = "zh-CN") + private String language; + + @Schema(description = "音色类型:female-女声,male-男声", example = "female") + private String gender; + + @Schema(description = "备注", example = "这是一个测试配音") + private String note; + +} + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoicePageReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoicePageReqVO.java new file mode 100644 index 0000000000..b0939883c1 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoicePageReqVO.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.module.tik.voice.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 用户 App - 用户配音分页 Request VO + * + * @author 芋道源码 + */ +@Schema(description = "用户 App - 用户配音分页 Request VO") +@Data +public class AppTikUserVoicePageReqVO extends PageParam { + + @Schema(description = "用户编号(自动填充,无需传递)") + private Long userId; + + @Schema(description = "配音名称(模糊查询)", example = "我的配音") + private String name; + +} + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoiceRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoiceRespVO.java new file mode 100644 index 0000000000..ff5a1e4993 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoiceRespVO.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.tik.voice.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 用户 App - 用户配音 Response VO + * + * @author 芋道源码 + */ +@Schema(description = "用户 App - 用户配音 Response VO") +@Data +public class AppTikUserVoiceRespVO { + + @Schema(description = "配音编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long id; + + @Schema(description = "配音名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "我的配音") + private String name; + + @Schema(description = "音频文件编号(关联 infra_file.id)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long fileId; + + @Schema(description = "文件访问URL(通过 file_id 关联查询获取)") + private String fileUrl; + + @Schema(description = "语音识别内容", example = "这是识别出的文字内容") + private String transcription; + + @Schema(description = "语言:zh-CN-简体中文,zh-TW-繁體中文,en-US-English", example = "zh-CN") + private String language; + + @Schema(description = "音色类型:female-女声,male-男声", example = "female") + private String gender; + + @Schema(description = "备注", example = "这是一个测试配音") + private String note; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoiceUpdateReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoiceUpdateReqVO.java new file mode 100644 index 0000000000..433a2fb652 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoiceUpdateReqVO.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.tik.voice.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 用户 App - 更新配音 Request VO + * + * @author 芋道源码 + */ +@Schema(description = "用户 App - 更新配音 Request VO") +@Data +public class AppTikUserVoiceUpdateReqVO { + + @Schema(description = "配音编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "配音编号不能为空") + private Long id; + + @Schema(description = "配音名称", example = "我的配音") + private String name; + + @Schema(description = "语言:zh-CN-简体中文,zh-TW-繁體中文,en-US-English", example = "zh-CN") + private String language; + + @Schema(description = "音色类型:female-女声,male-男声", example = "female") + private String gender; + + @Schema(description = "备注", example = "这是一个测试配音") + private String note; + + @Schema(description = "识别内容", example = "识别文字,可手动编辑") + private String transcription; + +} + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoicePreviewReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoicePreviewReqVO.java new file mode 100644 index 0000000000..30231e4b74 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoicePreviewReqVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.tik.voice.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 我的音色试听请求 + */ +@Data +public class AppTikVoicePreviewReqVO { + + @Schema(description = "输入文本") + @Size(max = 4000, message = "输入文本不能超过 4000 个字符") + private String inputText; + + @Schema(description = "识别文本,用于拼接") + @Size(max = 4000, message = "识别文本不能超过 4000 个字符") + private String transcriptionText; + + @Schema(description = "音色 ID(CosyVoice voiceId)") + private String voiceId; + + @Schema(description = "音色源音频 OSS 地址(当没有 voiceId 时必传)") + private String fileUrl; + + @Schema(description = "模型名称,默认 cosyvoice-v2") + private String model; + + @Schema(description = "语速", example = "1.0") + private Float speechRate; + + @Schema(description = "音量", example = "0") + private Float volume; + + @Schema(description = "情感", example = "neutral") + private String emotion; + + @Schema(description = "音频格式,默认 wav") + private String audioFormat; +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoicePreviewRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoicePreviewRespVO.java new file mode 100644 index 0000000000..3d3bf18e7f --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoicePreviewRespVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.tik.voice.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "音色试听响应") +public class AppTikVoicePreviewRespVO { + + @Schema(description = "音频播放地址(预签名 URL)") + private String audioUrl; + + @Schema(description = "音频格式", example = "wav") + private String format; + + @Schema(description = "采样率", example = "24000") + private Integer sampleRate; + + @Schema(description = "CosyVoice 请求ID") + private String requestId; + + @Schema(description = "使用的音色 ID") + private String voiceId; +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoiceTtsReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoiceTtsReqVO.java new file mode 100644 index 0000000000..5630e18685 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoiceTtsReqVO.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.tik.voice.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 文本转语音请求 VO + */ +@Data +public class AppTikVoiceTtsReqVO { + + @Schema(description = "输入文本") + @Size(max = 4000, message = "输入文本不能超过 4000 个字符") + private String inputText; + + @Schema(description = "识别文本,用于拼接") + @Size(max = 4000, message = "识别文本不能超过 4000 个字符") + private String transcriptionText; + + @Schema(description = "音色 ID(CosyVoice voiceId)", example = "cosyvoice-v2-myvoice-xxx") + private String voiceId; + + @Schema(description = "音色源音频 OSS 地址(当没有 voiceId 时必传)") + private String fileUrl; + + @Schema(description = "模型名称,默认 cosyvoice-v2", example = "cosyvoice-v3") + private String model; + + @Schema(description = "语速,默认 1.0", example = "1.0") + private Float speechRate; + + @Schema(description = "情感", example = "happy") + private String emotion; + + @Schema(description = "音量调节范围 [-10,10]", example = "0") + private Float volume; + + @Schema(description = "目标采样率,默认 24000") + private Integer sampleRate; + + @Schema(description = "音频格式,默认 wav,可选 mp3") + private String audioFormat; +} + + diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoiceTtsRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoiceTtsRespVO.java new file mode 100644 index 0000000000..0b386389c7 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoiceTtsRespVO.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.tik.voice.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "CosyVoice 文本转语音响应") +public class AppTikVoiceTtsRespVO { + + @Schema(description = "用户文件编号", example = "1024") + private Long fileId; + + @Schema(description = "音频播放地址(预签名 URL)") + private String audioUrl; + + @Schema(description = "音频格式", example = "mp3") + private String format; + + @Schema(description = "采样率", example = "24000") + private Integer sampleRate; + + @Schema(description = "CosyVoice 请求ID") + private String requestId; + + @Schema(description = "使用的音色 ID") + private String voiceId; +} + + diff --git a/yudao-module-tik/src/test/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncServiceImplTest.java b/yudao-module-tik/src/test/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncServiceImplTest.java new file mode 100644 index 0000000000..f2d18fd776 --- /dev/null +++ b/yudao-module-tik/src/test/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncServiceImplTest.java @@ -0,0 +1,62 @@ +package cn.iocoder.yudao.module.tik.voice.service; + +import cn.iocoder.yudao.module.tik.voice.client.LatentsyncClient; +import cn.iocoder.yudao.module.tik.voice.client.dto.LatentsyncSubmitRequest; +import cn.iocoder.yudao.module.tik.voice.client.dto.LatentsyncSubmitResponse; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikLatentsyncSubmitReqVO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikLatentsyncSubmitRespVO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LatentsyncServiceImplTest { + + @Mock + private LatentsyncClient latentsyncClient; + + private LatentsyncServiceImpl latentsyncService; + + @BeforeEach + void setUp() { + latentsyncService = new LatentsyncServiceImpl(latentsyncClient); + } + + @Test + void submitTask_success() { + AppTikLatentsyncSubmitReqVO reqVO = new AppTikLatentsyncSubmitReqVO(); + reqVO.setAudioUrl("https://cdn.example.com/audio.wav"); + reqVO.setVideoUrl("https://cdn.example.com/video.mp4"); + reqVO.setGuidanceScale(2); + reqVO.setSeed(999); + + LatentsyncSubmitResponse clientResp = new LatentsyncSubmitResponse(); + clientResp.setRequestId("task-123"); + clientResp.setStatus("IN_QUEUE"); + clientResp.setQueuePosition(0); + when(latentsyncClient.submitTask(org.mockito.Mockito.any())).thenReturn(clientResp); + + AppTikLatentsyncSubmitRespVO respVO = latentsyncService.submitTask(reqVO); + + assertThat(respVO.getRequestId()).isEqualTo("task-123"); + assertThat(respVO.getStatus()).isEqualTo("IN_QUEUE"); + assertThat(respVO.getQueuePosition()).isZero(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LatentsyncSubmitRequest.class); + verify(latentsyncClient).submitTask(captor.capture()); + LatentsyncSubmitRequest submitRequest = captor.getValue(); + assertThat(submitRequest.getAudioUrl()).isEqualTo(reqVO.getAudioUrl()); + assertThat(submitRequest.getVideoUrl()).isEqualTo(reqVO.getVideoUrl()); + assertThat(submitRequest.getGuidanceScale()).isEqualTo(reqVO.getGuidanceScale()); + assertThat(submitRequest.getSeed()).isEqualTo(reqVO.getSeed()); + } +} + + 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