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

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