send-stream

This commit is contained in:
wing
2025-11-19 00:12:47 +08:00
parent 7f53203245
commit 33abc33b58
21 changed files with 1630 additions and 2247 deletions

View File

@@ -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`: 父分组ID0表示根分组
- `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目录懒加载初始化
生成完整路径
上传到OSSFileApi
保存元数据到 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**
- ✅ 架构清晰,分层合理
- ✅ 路径设计合理,支持多租户
- ✅ 配额管理完善
- ⚠️ 物理删除功能缺失
- ⚠️ 预览图功能未实现
- ⚠️ 部分字段未充分利用

View File

@@ -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.1252毫秒后
→ 生成时间戳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<FileDO>()
.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%准确。

View File

@@ -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. 上传到OSSFileService.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 提取路径
- ✅ 可行性:完全可行
**建议**
- 当前实现已经是最优方案
- 路径准确性有保障
- 代码逻辑清晰,无冗余

View File

@@ -1,76 +0,0 @@
# 文件上传策略分析
## 🎯 业界成熟方案先上传OSS再存数据库
### 方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|------|------|------|----------|
| **先上传OSS再存数据库** ✅ | 1. OSS上传失败不影响数据库<br>2. 数据库事务可快速回滚<br>3. 用户体验好(文件已上传)<br>4. 孤立文件可定时清理 | 1. 数据库失败会产生孤立文件<br>2. 需要清理机制 | **推荐方案**(业界主流) |
| 先存数据库再上传OSS | 1. 数据库失败不会上传OSS<br>2. 不会产生孤立文件 | 1. OSS上传失败需要回滚数据库<br>2. 数据库事务时间长<br>3. 用户体验差 | 不推荐 |
### 为什么选择"先上传OSS再存数据库"
1. **性能优势**
- OSS上传是外部服务调用不应该阻塞数据库事务
- 数据库事务时间短,减少锁竞争
2. **可靠性优势**
- OSS上传失败直接返回错误不产生脏数据
- 数据库保存失败OSS文件可以后续清理定时任务
3. **用户体验优势**
- 文件已上传成功,即使数据库失败,文件还在
- 可以重试数据库保存,无需重新上传
4. **业界实践**
- 阿里云、腾讯云、AWS 等主流云服务都推荐此方案
- 大多数开源项目采用此方案
### 当前实现方案
```
1. 校验(文件分类、配额)
2. 读取文件内容
3. 上传到OSSFileService.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上传失败率
- 监控数据库保存失败率
- 监控孤立文件数量

View File

@@ -8,6 +8,7 @@ import cn.iocoder.yudao.module.tik.chat.vo.app.AppAiChatMessageRespVO;
import cn.iocoder.yudao.module.tik.chat.vo.app.AppAiChatMessageSendReqVO;
import cn.iocoder.yudao.module.tik.chat.vo.app.AppAiChatMessageSendRespVO;
import cn.iocoder.yudao.module.tik.controller.admin.chat.vo.message.AiChatMessageSendReqVO;
import cn.iocoder.yudao.module.tik.controller.admin.chat.vo.message.AiChatMessageSendRespVO;
import cn.iocoder.yudao.module.tik.dal.dataobject.chat.AiChatConversationDO;
import cn.iocoder.yudao.module.tik.dal.dataobject.chat.AiChatMessageDO;
import cn.iocoder.yudao.module.tik.dal.dataobject.knowledge.AiKnowledgeDocumentDO;
@@ -116,19 +117,8 @@ public class AppAiChatMessageController {
@Operation(summary = "发送消息(流式)", description = "流式返回,响应较快")
@PostMapping(value = "/send-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<CommonResult<AppAiChatMessageSendRespVO>> sendChatMessageStream(@Valid @RequestBody AppAiChatMessageSendReqVO sendReqVO) {
// 将 App VO 转换为 Admin VO
AiChatMessageSendReqVO adminReqVO = BeanUtils.toBean(sendReqVO, AiChatMessageSendReqVO.class);
// 调用 Service然后转换响应流
return chatMessageService.sendChatMessageStream(adminReqVO, getLoginUserId())
.map(result -> {
if (result.getData() != null) {
// 手动转换 segments因为内部类类型不同
AppAiChatMessageSendRespVO appResp = convertSendRespVO(result.getData());
return success(appResp);
}
return success((AppAiChatMessageSendRespVO) null);
});
public Flux<CommonResult<AiChatMessageSendRespVO>> sendChatMessageStream(@Valid @RequestBody AiChatMessageSendReqVO sendReqVO) {
return chatMessageService.sendChatMessageStream(sendReqVO, getLoginUserId());
}
@Operation(summary = "获得指定对话的消息列表")

View File

@@ -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, "口型同步任务提交失败");
}

View File

@@ -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. 上传文件到OSSFileService会自动处理文件名添加日期前缀和时间戳后缀
// 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 表直接获取文件IDMyBatis Plus 会自动填充自增ID
FileDO infraFile = new FileDO()
.setConfigId(client.getId())
.setName(fileName)
.setPath(filePath)
.setUrl(fileUrl)
.setType(fileType)
.setSize((int) file.getSize());
fileMapper.insert(infraFile);
infraFileId = infraFile.getId(); // MyBatis Plus 会自动填充自增ID
log.info("[uploadFile][文件上传成功,文件编号({}),路径({})]", infraFileId, filePath);
} catch (Exception e) {
log.error("[uploadFile][上传OSS失败]", e);
throw exception(FILE_NOT_EXISTS, "上传OSS失败" + e.getMessage());
@@ -115,7 +151,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
// ========== 第三阶段保存数据库在事务中如果失败则删除OSS文件 ==========
try {
return saveFileRecord(userId, file, fileCategory, fileUrl, filePath, coverBase64, baseDirectory);
return saveFileRecord(userId, file, fileCategory, fileUrl, filePath, coverBase64, baseDirectory, infraFileId);
} catch (Exception e) {
// 数据库保存失败删除已上传的OSS文件
log.error("[uploadFile][保存数据库失败准备删除OSS文件URL({})]", fileUrl, e);
@@ -129,8 +165,14 @@ public class TikUserFileServiceImpl implements TikUserFileService {
*/
@Transactional(rollbackFor = Exception.class)
public Long saveFileRecord(Long userId, MultipartFile file, String fileCategory,
String fileUrl, String filePath, String coverBase64, String baseDirectory) {
// 7. 处理视频封面(如果有前端传递的 base64 封面,先处理封面再插入主记录
String fileUrl, String filePath, String coverBase64, String baseDirectory, Long infraFileId) {
// 7. 验证 infraFileId 不为空(必须在保存记录之前检查
if (infraFileId == null) {
log.error("[saveFileRecord][infra_file.id 为空,无法保存文件记录,用户({})URL({})]", userId, fileUrl);
throw exception(FILE_NOT_EXISTS, "文件记录保存失败无法获取文件ID");
}
// 8. 处理视频封面(如果有前端传递的 base64 封面,先处理封面再插入主记录)
String coverUrl = null;
if (StrUtil.isNotBlank(coverBase64) && StrUtil.containsIgnoreCase(file.getContentType(), "video")) {
try {
@@ -162,7 +204,8 @@ public class TikUserFileServiceImpl implements TikUserFileService {
// 严格验证:确保返回的是有效的 URL而不是 base64 字符串
if (StrUtil.isNotBlank(uploadedUrl) && !uploadedUrl.equals(coverBase64) && !uploadedUrl.contains("data:image")) {
coverUrl = uploadedUrl;
// 移除预签名URL中的签名参数获取基础URL用于存储
coverUrl = HttpUtils.removeUrlQuery(uploadedUrl);
log.info("[saveFileRecord][视频封面上传成功封面URL({})]", coverUrl);
} else {
log.error("[saveFileRecord][视频封面上传返回无效URL跳过保存封面。返回URL: {}", uploadedUrl);
@@ -177,10 +220,10 @@ public class TikUserFileServiceImpl implements TikUserFileService {
}
}
// 8. 创建文件记录保存完整路径包含封面URL和Base64
// 9. 创建文件记录保存完整路径包含封面URL和Base64
TikUserFileDO userFile = new TikUserFileDO()
.setUserId(userId)
.setFileId(null) // 显式设置为nullfile_id是可选的用于关联infra_file表
.setFileId(infraFileId) // 关联infra_file表用于后续通过FileService管理文件
.setFileName(file.getOriginalFilename()) // 保存原始文件名,用于展示
.setFileType(file.getContentType())
.setFileCategory(fileCategory)
@@ -191,11 +234,12 @@ public class TikUserFileServiceImpl implements TikUserFileService {
.setCoverBase64(StrUtil.isNotBlank(coverBase64) ? coverBase64 : null); // 保存原始base64数据如果有
userFileMapper.insert(userFile);
// 9. 更新配额
// 10. 更新配额
quotaService.increaseUsedStorage(userId, file.getSize());
log.info("[saveFileRecord][用户({})保存文件记录成功,文件编号({})]", userId, userFile.getId());
return userFile.getId();
log.info("[saveFileRecord][用户({})保存文件记录成功,文件编号({})infra文件编号({})]", userId, userFile.getId(), infraFileId);
// 返回 infra_file.id因为创建配音等操作需要使用 infra_file.id
return infraFileId;
}
/**
@@ -221,31 +265,41 @@ public class TikUserFileServiceImpl implements TikUserFileService {
}
/**
* 从 infra_file 表查询文件信息(返回完整对象,包含 id
* 生成上传路径(与 FileService 保持一致
* 格式:{directory}/{yyyyMMdd}/{filename}_{timestamp}.ext
*/
private FileDO getInfraFileByUrl(String fileUrl, long fileSize) {
if (StrUtil.isBlank(fileUrl)) {
return null;
private String generateUploadPath(String name, String directory) {
// 1. 生成前缀、后缀
String prefix = null;
boolean PATH_PREFIX_DATE_ENABLE = true;
boolean PATH_SUFFIX_TIMESTAMP_ENABLE = true;
if (PATH_PREFIX_DATE_ENABLE) {
prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN);
}
try {
// 移除URL中的查询参数如果有
String cleanUrl = fileUrl;
if (fileUrl.contains("?")) {
cleanUrl = fileUrl.substring(0, fileUrl.indexOf("?"));
String suffix = null;
if (PATH_SUFFIX_TIMESTAMP_ENABLE) {
suffix = String.valueOf(System.currentTimeMillis());
}
// 2.1 先拼接 suffix 后缀
if (StrUtil.isNotEmpty(suffix)) {
String ext = FileUtil.extName(name);
if (StrUtil.isNotEmpty(ext)) {
name = FileUtil.mainName(name) + "_" + suffix + "." + ext;
} else {
name = name + "_" + suffix;
}
// 通过 URL 和文件大小查询(提高准确性)
return fileMapper.selectOne(
new LambdaQueryWrapperX<FileDO>()
.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);

View File

@@ -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 {

View File

@@ -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为nullfileLink({})]", 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<String> 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,"录音文件识别请求失败!");
}
}