AI 面试业务逻辑初始化
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
package com.vetti.socket;
|
||||||
|
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class ChatWebSocketConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ServerEndpointExporter serverEndpointExporter() {
|
||||||
|
return new ServerEndpointExporter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package com.vetti.socket;
|
||||||
|
|
||||||
|
import com.vetti.common.ai.elevenLabs.ElevenLabsClient;
|
||||||
|
import com.vetti.common.ai.gpt.ChatGPTClient;
|
||||||
|
import com.vetti.common.ai.whisper.WhisperClient;
|
||||||
|
import com.vetti.common.config.RuoYiConfig;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.sound.sampled.AudioFileFormat;
|
||||||
|
import javax.sound.sampled.AudioFormat;
|
||||||
|
import javax.sound.sampled.AudioInputStream;
|
||||||
|
import javax.sound.sampled.AudioSystem;
|
||||||
|
import javax.websocket.*;
|
||||||
|
import javax.websocket.server.PathParam;
|
||||||
|
import javax.websocket.server.ServerEndpoint;
|
||||||
|
import java.io.*;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.channels.FileChannel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语音面试 web处理器
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@ServerEndpoint("/voice-websocket/{clientId}")
|
||||||
|
@Component
|
||||||
|
public class ChatWebSocketHandler {
|
||||||
|
|
||||||
|
// 音频参数配置 - 根据实际发送的音频流参数调整
|
||||||
|
private static final float SAMPLE_RATE = 16000.0f; // 采样率
|
||||||
|
private static final int SAMPLE_SIZE_IN_BITS = 16; // 采样位数
|
||||||
|
private static final int CHANNELS = 1; // 声道数 (1=单声道, 2=立体声)
|
||||||
|
private static final boolean SIGNED = true; // 是否有符号
|
||||||
|
private static final boolean BIG_ENDIAN = false; // 字节序
|
||||||
|
|
||||||
|
// 语音文件保存目录
|
||||||
|
private static final String VOICE_STORAGE_DIR = "/voice_files/";
|
||||||
|
|
||||||
|
// 语音结果文件保存目录
|
||||||
|
private static final String VOICE_STORAGE_RESULT_DIR = "/voice_result_files/";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ElevenLabsClient elevenLabsClient;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ChatGPTClient chatGPTClient;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WhisperClient whisperClient;
|
||||||
|
|
||||||
|
|
||||||
|
public ChatWebSocketHandler() {
|
||||||
|
// 初始化存储目录
|
||||||
|
File dir = new File(RuoYiConfig.getProfile()+VOICE_STORAGE_DIR);
|
||||||
|
if (!dir.exists()) {
|
||||||
|
dir.mkdirs();
|
||||||
|
}
|
||||||
|
|
||||||
|
File resultDir = new File(RuoYiConfig.getProfile()+VOICE_STORAGE_RESULT_DIR);
|
||||||
|
if (!resultDir.exists()) {
|
||||||
|
resultDir.mkdirs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接建立时调用
|
||||||
|
@OnOpen
|
||||||
|
public void onOpen(Session session, @PathParam("clientId") String clientId) {
|
||||||
|
log.info("WebSocket 链接已建立:{}",clientId);
|
||||||
|
//创建会话
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接收文本消息
|
||||||
|
@OnMessage
|
||||||
|
public void onTextMessage(Session session, String message) {
|
||||||
|
System.out.println("接收到文本消息: " + message);
|
||||||
|
// 可以在这里处理文本流数据
|
||||||
|
try {
|
||||||
|
// 发送响应
|
||||||
|
session.getBasicRemote().sendText("已收到文本: " + message);
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接收二进制消息(流数据)
|
||||||
|
@OnMessage
|
||||||
|
public void onBinaryMessage(Session session, @PathParam("clientId") String clientId, ByteBuffer byteBuffer) {
|
||||||
|
try{
|
||||||
|
log.info("客户端ID为:{}",clientId);
|
||||||
|
// 处理二进制流数据
|
||||||
|
byte[] data = new byte[byteBuffer.remaining()];
|
||||||
|
// 生成唯一文件名
|
||||||
|
String fileName = clientId + "_" + System.currentTimeMillis() + ".mp3";
|
||||||
|
String pathUrl = RuoYiConfig.getProfile()+VOICE_STORAGE_DIR + fileName;
|
||||||
|
log.info("文件路径为:{}",pathUrl);
|
||||||
|
saveAsWebM(data,pathUrl);
|
||||||
|
// //拿到文件进行文字转换
|
||||||
|
// String resultText = whisperClient.handleVoiceToText(pathUrl);
|
||||||
|
// //把提问的文字发送给CPT
|
||||||
|
// String resultMsg = chatGPTClient.handleAiChat(resultText);
|
||||||
|
// //把结果文字转成语音文件
|
||||||
|
// //生成文件
|
||||||
|
// // 生成唯一文件名
|
||||||
|
// String resultFileName = clientId + "_" + System.currentTimeMillis() + ".webm";
|
||||||
|
// String resultPathUrl = RuoYiConfig.getProfile()+VOICE_STORAGE_DIR + resultFileName;
|
||||||
|
// elevenLabsClient.handleTextToVoice(resultMsg,resultPathUrl);
|
||||||
|
//把语音文件转换成流,发送给前端
|
||||||
|
System.out.println("接收到二进制数据,长度: " + data.length + " bytes");
|
||||||
|
try {
|
||||||
|
//文件转换成文件流
|
||||||
|
// ByteBuffer outByteBuffer = convertFileToByteBuffer(resultPathUrl);
|
||||||
|
// session.getBasicRemote().sendBinary(outByteBuffer);
|
||||||
|
// 发送响应确认
|
||||||
|
session.getBasicRemote().sendText("已收到二进制数据,长度: " + data.length);
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}catch (Exception e){
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接关闭时调用
|
||||||
|
@OnClose
|
||||||
|
public void onClose(Session session, CloseReason reason) {
|
||||||
|
System.out.println("WebSocket连接已关闭: " + session.getId() + ", 原因: " + reason.getReasonPhrase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发生错误时调用
|
||||||
|
@OnError
|
||||||
|
public void onError(Session session, Throwable throwable) {
|
||||||
|
System.err.println("WebSocket错误发生: " + throwable.getMessage());
|
||||||
|
throwable.printStackTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将字节数组保存为WebM文件
|
||||||
|
* @param byteData 包含WebM数据的字节数组
|
||||||
|
* @param filePath 目标文件路径
|
||||||
|
* @return 操作是否成功
|
||||||
|
*/
|
||||||
|
private boolean saveAsWebM(byte[] byteData, String filePath) {
|
||||||
|
// 检查输入参数
|
||||||
|
if (byteData == null || byteData.length == 0) {
|
||||||
|
System.err.println("字节数组为空,无法生成WebM文件");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filePath == null || filePath.trim().isEmpty()) {
|
||||||
|
System.err.println("文件路径不能为空");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保文件以.webm结尾
|
||||||
|
if (!filePath.toLowerCase().endsWith(".webm")) {
|
||||||
|
filePath += ".webm";
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOutputStream fos = null;
|
||||||
|
try {
|
||||||
|
fos = new FileOutputStream(filePath);
|
||||||
|
fos.write(byteData);
|
||||||
|
fos.flush();
|
||||||
|
System.out.println("WebM文件已成功生成: " + filePath);
|
||||||
|
return true;
|
||||||
|
} catch (IOException e) {
|
||||||
|
System.err.println("写入文件时发生错误: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
} finally {
|
||||||
|
if (fos != null) {
|
||||||
|
try {
|
||||||
|
fos.close();
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将接收的音频数据保存为WAV文件
|
||||||
|
*/
|
||||||
|
private void saveAudioToFile(byte[] audioData,String pathUrl) {
|
||||||
|
if (audioData == null || audioData.length == 0) {
|
||||||
|
System.out.println("没有接收到音频数据,不保存文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 合并所有音频数据
|
||||||
|
// 创建音频格式
|
||||||
|
AudioFormat format = new AudioFormat(
|
||||||
|
SAMPLE_RATE,
|
||||||
|
SAMPLE_SIZE_IN_BITS,
|
||||||
|
CHANNELS,
|
||||||
|
SIGNED,
|
||||||
|
BIG_ENDIAN
|
||||||
|
);
|
||||||
|
// 创建音频输入流
|
||||||
|
ByteArrayInputStream bais = new ByteArrayInputStream(audioData);
|
||||||
|
AudioInputStream ais = new AudioInputStream(bais, format, audioData.length / format.getFrameSize());
|
||||||
|
// 保存为WAV文件
|
||||||
|
File outputFile = new File(pathUrl);
|
||||||
|
AudioSystem.write(ais, AudioFileFormat.Type.WAVE, outputFile);
|
||||||
|
System.out.println("音频文件保存成功: " + outputFile.getAbsolutePath());
|
||||||
|
} catch (IOException e) {
|
||||||
|
System.err.println("保存音频文件失败: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File 转换成 ByteBuffer
|
||||||
|
* @param fileUrl 文件路径
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
private ByteBuffer convertFileToByteBuffer(String fileUrl){
|
||||||
|
File file = new File(fileUrl);
|
||||||
|
// 使用RandomAccessFile获取FileChannel
|
||||||
|
try (RandomAccessFile raf = new RandomAccessFile(file, "r");
|
||||||
|
FileChannel channel = raf.getChannel()) {
|
||||||
|
// 分配与文件大小相同的ByteBuffer
|
||||||
|
ByteBuffer buffer = ByteBuffer.allocate((int) channel.size());
|
||||||
|
// 从通道读取数据到缓冲区
|
||||||
|
channel.read(buffer);
|
||||||
|
// 切换为读模式
|
||||||
|
buffer.flip();
|
||||||
|
return buffer;
|
||||||
|
}catch (Exception e){
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package com.vetti.socket;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
|
||||||
import org.springframework.web.socket.server.HandshakeInterceptor;
|
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@EnableWebSocket
|
|
||||||
public class FileReceiverConfig implements WebSocketConfigurer {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public FileReceiverWebSocketHandler fileReceiverWebSocketHandler() {
|
|
||||||
return new FileReceiverWebSocketHandler();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
|
||||||
registry.addHandler(fileReceiverWebSocketHandler(), "/voice-websocket111111")
|
|
||||||
.addInterceptors(handshakeInterceptor())
|
|
||||||
.setAllowedOrigins("*"); // 生产环境需替换为具体域名
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public HandshakeInterceptor handshakeInterceptor() {
|
|
||||||
return new HandshakeInterceptor() {
|
|
||||||
@Override
|
|
||||||
public boolean beforeHandshake(org.springframework.http.server.ServerHttpRequest request,
|
|
||||||
org.springframework.http.server.ServerHttpResponse response,
|
|
||||||
org.springframework.web.socket.WebSocketHandler wsHandler,
|
|
||||||
Map<String, Object> attributes) throws Exception {
|
|
||||||
if (request instanceof org.springframework.http.server.ServletServerHttpRequest) {
|
|
||||||
HttpServletRequest servletRequest =
|
|
||||||
((org.springframework.http.server.ServletServerHttpRequest) request).getServletRequest();
|
|
||||||
String clientId = servletRequest.getParameter("clientId");
|
|
||||||
if (clientId != null && !clientId.isEmpty()) {
|
|
||||||
attributes.put("clientId", clientId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void afterHandshake(org.springframework.http.server.ServerHttpRequest request,
|
|
||||||
org.springframework.http.server.ServerHttpResponse response,
|
|
||||||
org.springframework.web.socket.WebSocketHandler wsHandler,
|
|
||||||
Exception exception) {
|
|
||||||
// 握手后处理
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package com.vetti.socket;
|
|
||||||
|
|
||||||
import com.vetti.socket.vo.FileMetadata;
|
|
||||||
import com.vetti.socket.vo.FileTransferState;
|
|
||||||
import org.springframework.web.socket.*;
|
|
||||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import java.io.*;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.nio.file.*;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
|
|
||||||
public class FileReceiverWebSocketHandler extends TextWebSocketHandler {
|
|
||||||
// 存储客户端的文件传输状态:clientId -> FileTransferState
|
|
||||||
private final Map<String, FileTransferState> transferStates = new ConcurrentHashMap<>();
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
||||||
private static final String STORAGE_DIR = "received_files/";
|
|
||||||
|
|
||||||
// 初始化存储目录
|
|
||||||
static {
|
|
||||||
try {
|
|
||||||
Files.createDirectories(Paths.get(STORAGE_DIR));
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new RuntimeException("无法创建文件存储目录", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
|
||||||
String clientId = (String) session.getAttributes().get("clientId");
|
|
||||||
transferStates.put(clientId, new FileTransferState());
|
|
||||||
System.out.println("客户端连接: " + clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理文本消息(文件元数据)
|
|
||||||
@Override
|
|
||||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
|
||||||
String clientId = (String) session.getAttributes().get("clientId");
|
|
||||||
FileMetadata metadata = objectMapper.readValue(message.getPayload(), FileMetadata.class);
|
|
||||||
|
|
||||||
// 初始化文件传输状态
|
|
||||||
FileTransferState state = transferStates.get(clientId);
|
|
||||||
state.setFileName(metadata.getFileName());
|
|
||||||
state.setTotalSize(metadata.getTotalSize());
|
|
||||||
state.setTotalParts(metadata.getTotalParts());
|
|
||||||
state.setOutputStream(new FileOutputStream(STORAGE_DIR + metadata.getFileName()));
|
|
||||||
|
|
||||||
System.out.println("开始接收文件: " + metadata.getFileName() + " (" + metadata.getTotalParts() + "个分片)");
|
|
||||||
|
|
||||||
// 确认已收到元数据
|
|
||||||
session.sendMessage(new TextMessage("{\"type\":\"metadata_ack\"}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理二进制消息(文件分片)
|
|
||||||
@Override
|
|
||||||
protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message){
|
|
||||||
try{
|
|
||||||
System.out.println("开始-接收文件分片数据流");
|
|
||||||
String clientId = (String) session.getAttributes().get("clientId");
|
|
||||||
FileTransferState state = transferStates.get(clientId);
|
|
||||||
|
|
||||||
if (state == null || state.getOutputStream() == null) {
|
|
||||||
session.sendMessage(new TextMessage("{\"type\":\"error\", \"message\":\"未收到文件元数据\"}"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
System.out.println("进行中-接收文件分片数据流");
|
|
||||||
// 解析分片数据
|
|
||||||
ByteBuffer payload = message.getPayload();
|
|
||||||
// int partNumber = payload.getInt(); // 前4字节是分片编号
|
|
||||||
byte[] data = new byte[payload.remaining()];
|
|
||||||
payload.get(data);
|
|
||||||
|
|
||||||
// 写入文件
|
|
||||||
state.getOutputStream().write(data);
|
|
||||||
state.incrementReceivedParts();
|
|
||||||
|
|
||||||
// 发送进度更新(每5个分片或最后一个分片)
|
|
||||||
if (state.getReceivedParts() % 5 == 0 || state.getReceivedParts() == state.getTotalParts()) {
|
|
||||||
// 检查是否接收完成
|
|
||||||
if (state.getReceivedParts() == state.getTotalParts()) {
|
|
||||||
System.out.println("生成完整的文件-接收文件分片数据流");
|
|
||||||
completeFileTransfer(session, state, clientId);
|
|
||||||
//进行文件数据转换
|
|
||||||
|
|
||||||
//获取最终的文件结果
|
|
||||||
|
|
||||||
//把文件转成对应的文件流,返回给前端
|
|
||||||
// session.sendMessage(new BinaryMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}catch (Exception e){
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 完成文件传输
|
|
||||||
private void completeFileTransfer(WebSocketSession session, FileTransferState state, String clientId) throws IOException {
|
|
||||||
// 关闭文件输出流
|
|
||||||
state.getOutputStream().close();
|
|
||||||
|
|
||||||
// 验证文件大小
|
|
||||||
File file = new File(STORAGE_DIR + state.getFileName());
|
|
||||||
boolean fileValid = file.length() == state.getTotalSize();
|
|
||||||
|
|
||||||
// 发送完成消息
|
|
||||||
String result = fileValid ?
|
|
||||||
"{\"type\":\"complete\", \"message\":\"文件接收完成\", \"filePath\":\"" + file.getAbsolutePath() + "\"}" :
|
|
||||||
"{\"type\":\"error\", \"message\":\"文件损坏,大小不匹配\"}";
|
|
||||||
session.sendMessage(new TextMessage(result));
|
|
||||||
|
|
||||||
System.out.println("文件接收" + (fileValid ? "完成" : "失败") + ": " + state.getFileName());
|
|
||||||
|
|
||||||
// 清理状态
|
|
||||||
transferStates.remove(clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
|
|
||||||
String clientId = (String) session.getAttributes().get("clientId");
|
|
||||||
FileTransferState state = transferStates.remove(clientId);
|
|
||||||
|
|
||||||
// 关闭可能存在的文件流
|
|
||||||
if (state != null && state.getOutputStream() != null) {
|
|
||||||
try {
|
|
||||||
state.getOutputStream().close();
|
|
||||||
// 删除未完成的文件
|
|
||||||
Files.deleteIfExists(Paths.get(STORAGE_DIR + state.getFileName()));
|
|
||||||
} catch (IOException e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
System.out.println("客户端断开连接: " + clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
|
|
||||||
System.err.println("传输错误: " + exception.getMessage());
|
|
||||||
session.close(CloseStatus.SERVER_ERROR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.vetti.socket;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
|
||||||
|
|
||||||
|
import javax.websocket.server.ServerEndpointConfig;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class LargeMessageWebSocketConfigurator extends ServerEndpointConfig.Configurator {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ServletServerContainerFactoryBean createWebSocketContainer() {
|
||||||
|
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
|
||||||
|
container.setMaxTextMessageBufferSize(1048576); // 文本消息缓冲区
|
||||||
|
container.setMaxBinaryMessageBufferSize(10485760); // 二进制消息缓冲区
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package com.vetti.socket;
|
|
||||||
|
|
||||||
import org.springframework.http.server.ServerHttpRequest;
|
|
||||||
import org.springframework.http.server.ServerHttpResponse;
|
|
||||||
import org.springframework.http.server.ServletServerHttpRequest;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.web.socket.WebSocketHandler;
|
|
||||||
import org.springframework.web.socket.server.HandshakeInterceptor;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class VoiceHandshakeInterceptor implements HandshakeInterceptor {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
|
|
||||||
WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
|
|
||||||
// 从请求参数中获取客户端ID
|
|
||||||
if (request instanceof ServletServerHttpRequest) {
|
|
||||||
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
|
|
||||||
String clientId = servletRequest.getServletRequest().getParameter("clientId");
|
|
||||||
if (clientId != null && !clientId.isEmpty()) {
|
|
||||||
attributes.put("clientId", clientId);
|
|
||||||
System.out.println("客户端连接: " + clientId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
|
|
||||||
WebSocketHandler wsHandler, Exception exception) {
|
|
||||||
// 握手后操作,可留空
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
package com.vetti.socket;
|
|
||||||
|
|
||||||
import cn.hutool.json.JSONUtil;
|
|
||||||
import com.vetti.socket.vo.FileTransferState;
|
|
||||||
import com.vetti.socket.vo.VoicePartMessage;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.web.socket.BinaryMessage;
|
|
||||||
import org.springframework.web.socket.CloseStatus;
|
|
||||||
import org.springframework.web.socket.TextMessage;
|
|
||||||
import org.springframework.web.socket.WebSocketSession;
|
|
||||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import java.io.*;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.locks.ReentrantLock;
|
|
||||||
import java.util.Base64;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
public class VoiceWebSocketHandler extends TextWebSocketHandler {
|
|
||||||
|
|
||||||
// 存储每个客户端的语音分片,key: clientId, value: 分片映射
|
|
||||||
private final Map<String, Map<Integer, byte[]>> clientVoiceParts = new ConcurrentHashMap<>();
|
|
||||||
// 存储每个客户端的总分片数,key: clientId
|
|
||||||
private final Map<String, Integer> clientTotalParts = new ConcurrentHashMap<>();
|
|
||||||
// 用于并发控制的锁
|
|
||||||
private final Map<String, ReentrantLock> clientLocks = new ConcurrentHashMap<>();
|
|
||||||
// JSON序列化工具
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
||||||
// 语音文件保存目录
|
|
||||||
private static final String VOICE_STORAGE_DIR = "voice_files/";
|
|
||||||
|
|
||||||
public VoiceWebSocketHandler() {
|
|
||||||
// 初始化存储目录
|
|
||||||
File dir = new File(VOICE_STORAGE_DIR);
|
|
||||||
if (!dir.exists()) {
|
|
||||||
dir.mkdirs();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
|
||||||
String clientId = getClientId(session);
|
|
||||||
if (clientId != null) {
|
|
||||||
// 初始化客户端数据结构
|
|
||||||
clientVoiceParts.put(clientId, new TreeMap<>()); // TreeMap保证分片有序
|
|
||||||
clientLocks.putIfAbsent(clientId, new ReentrantLock());
|
|
||||||
System.out.println("客户端连接建立: " + clientId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
|
||||||
log.info("开始进入文本传输里面了");
|
|
||||||
String clientId = getClientId(session);
|
|
||||||
if (clientId == null) {
|
|
||||||
System.err.println("无法获取客户端ID");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 解析前端发送的JSON消息
|
|
||||||
VoicePartMessage voiceMessage = objectMapper.readValue(message.getPayload(), VoicePartMessage.class);
|
|
||||||
|
|
||||||
// 处理语音分片
|
|
||||||
if ("voice_part".equals(voiceMessage.getType())) {
|
|
||||||
processVoicePart(clientId, voiceMessage, session);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("处理消息出错: " + e.getMessage());
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理二进制消息(文件分片)
|
|
||||||
@Override
|
|
||||||
protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message){
|
|
||||||
log.info("开始进入文件流传输里面了");
|
|
||||||
log.info("获取的数据为:{}", JSONUtil.toJsonStr(message));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
|
|
||||||
String clientId = getClientId(session);
|
|
||||||
if (clientId != null) {
|
|
||||||
// 清理客户端资源
|
|
||||||
clientVoiceParts.remove(clientId);
|
|
||||||
clientTotalParts.remove(clientId);
|
|
||||||
clientLocks.remove(clientId);
|
|
||||||
System.out.println("客户端连接关闭: " + clientId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理语音分片
|
|
||||||
*/
|
|
||||||
private void processVoicePart(String clientId, VoicePartMessage message, WebSocketSession session) throws Exception {
|
|
||||||
ReentrantLock lock = clientLocks.get(clientId);
|
|
||||||
lock.lock(); // 加锁确保线程安全
|
|
||||||
try {
|
|
||||||
// 保存总分片数
|
|
||||||
clientTotalParts.put(clientId, message.getTotalParts());
|
|
||||||
|
|
||||||
// 解码Base64数据并存储分片
|
|
||||||
byte[] voiceData = Base64.getDecoder().decode(message.getData());
|
|
||||||
clientVoiceParts.get(clientId).put(message.getPartNumber(), voiceData);
|
|
||||||
|
|
||||||
System.out.printf("接收客户端 %s 的分片 %d/%d%n",
|
|
||||||
clientId, message.getPartNumber() + 1, message.getTotalParts());
|
|
||||||
|
|
||||||
// 检查是否所有分片都已接收
|
|
||||||
checkAndMergeParts(clientId, session);
|
|
||||||
} finally {
|
|
||||||
lock.unlock(); // 释放锁
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查是否所有分片都已接收,如果是则合并
|
|
||||||
*/
|
|
||||||
private void checkAndMergeParts(String clientId, WebSocketSession session) throws Exception {
|
|
||||||
Map<Integer, byte[]> parts = clientVoiceParts.get(clientId);
|
|
||||||
Integer totalParts = clientTotalParts.get(clientId);
|
|
||||||
|
|
||||||
if (parts == null || totalParts == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 所有分片都已接收
|
|
||||||
if (parts.size() == totalParts) {
|
|
||||||
System.out.println("所有分片接收完成,开始合并: " + clientId);
|
|
||||||
|
|
||||||
// 生成唯一文件名
|
|
||||||
String fileName = clientId + "_" + System.currentTimeMillis() + ".wav";
|
|
||||||
Path outputPath = Paths.get(VOICE_STORAGE_DIR + fileName);
|
|
||||||
|
|
||||||
// 合并分片
|
|
||||||
try (FileOutputStream fos = new FileOutputStream(outputPath.toFile())) {
|
|
||||||
for (byte[] part : parts.values()) {
|
|
||||||
fos.write(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println("语音文件合并完成,保存路径: " + outputPath);
|
|
||||||
|
|
||||||
// 向客户端发送处理完成消息
|
|
||||||
Map<String, Object> response = new HashMap<>();
|
|
||||||
response.put("type", "complete");
|
|
||||||
response.put("message", "语音接收完成");
|
|
||||||
response.put("fileName", fileName);
|
|
||||||
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
|
|
||||||
|
|
||||||
// 清理已合并的分片数据
|
|
||||||
clientVoiceParts.get(clientId).clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从会话中获取客户端ID
|
|
||||||
*/
|
|
||||||
private String getClientId(WebSocketSession session) {
|
|
||||||
return (String) session.getAttributes().get("clientId");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package com.vetti.socket;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
|
||||||
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@EnableWebSocket
|
|
||||||
public class WebSocketConfig implements WebSocketConfigurer {
|
|
||||||
|
|
||||||
private final VoiceWebSocketHandler voiceWebSocketHandler;
|
|
||||||
|
|
||||||
private final VoiceHandshakeInterceptor voiceHandshakeInterceptor;
|
|
||||||
|
|
||||||
// 构造函数注入
|
|
||||||
public WebSocketConfig(VoiceWebSocketHandler voiceWebSocketHandler,
|
|
||||||
VoiceHandshakeInterceptor interceptor) {
|
|
||||||
this.voiceWebSocketHandler = voiceWebSocketHandler;
|
|
||||||
this.voiceHandshakeInterceptor = interceptor;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
|
||||||
// 注册WebSocket处理器,设置路径和允许跨域
|
|
||||||
registry.addHandler(voiceWebSocketHandler, "/voice-websocket")
|
|
||||||
.addInterceptors(voiceHandshakeInterceptor)
|
|
||||||
.setAllowedOrigins("*"); // 生产环境应指定具体域名而非*
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package com.vetti.socket.vo;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件元数据类
|
|
||||||
*/
|
|
||||||
public class FileMetadata {
|
|
||||||
|
|
||||||
private String fileName;
|
|
||||||
private long totalSize;
|
|
||||||
private int totalParts;
|
|
||||||
|
|
||||||
// getter和setter方法
|
|
||||||
public String getFileName() { return fileName; }
|
|
||||||
public void setFileName(String fileName) { this.fileName = fileName; }
|
|
||||||
public long getTotalSize() { return totalSize; }
|
|
||||||
public void setTotalSize(long totalSize) { this.totalSize = totalSize; }
|
|
||||||
public int getTotalParts() { return totalParts; }
|
|
||||||
public void setTotalParts(int totalParts) { this.totalParts = totalParts; }
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.vetti.socket.vo;
|
|
||||||
|
|
||||||
import java.io.FileOutputStream;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件传输状态类
|
|
||||||
*/
|
|
||||||
public class FileTransferState {
|
|
||||||
|
|
||||||
private String fileName;
|
|
||||||
private long totalSize;
|
|
||||||
private int totalParts;
|
|
||||||
private int receivedParts = 0;
|
|
||||||
private FileOutputStream outputStream;
|
|
||||||
|
|
||||||
// getter和setter方法
|
|
||||||
public String getFileName() { return fileName; }
|
|
||||||
public void setFileName(String fileName) { this.fileName = fileName; }
|
|
||||||
public long getTotalSize() { return totalSize; }
|
|
||||||
public void setTotalSize(long totalSize) { this.totalSize = totalSize; }
|
|
||||||
public int getTotalParts() { return totalParts; }
|
|
||||||
public void setTotalParts(int totalParts) { this.totalParts = totalParts; }
|
|
||||||
public int getReceivedParts() { return receivedParts; }
|
|
||||||
public void incrementReceivedParts() { this.receivedParts++; }
|
|
||||||
public FileOutputStream getOutputStream() { return outputStream; }
|
|
||||||
public void setOutputStream(FileOutputStream outputStream) { this.outputStream = outputStream; }
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package com.vetti.socket.vo;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 语音分片消息实体类,对应前端发送的JSON结构
|
|
||||||
*/
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
public class VoicePartMessage {
|
|
||||||
private String type; // 消息类型,如"voice_part"
|
|
||||||
private int partNumber; // 分片编号,从0开始
|
|
||||||
private int totalParts; // 总分片数
|
|
||||||
private String data; // Base64编码的分片数据
|
|
||||||
|
|
||||||
// getter和setter
|
|
||||||
public String getType() {
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setType(String type) {
|
|
||||||
this.type = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getPartNumber() {
|
|
||||||
return partNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setPartNumber(int partNumber) {
|
|
||||||
this.partNumber = partNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getTotalParts() {
|
|
||||||
return totalParts;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setTotalParts(int totalParts) {
|
|
||||||
this.totalParts = totalParts;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getData() {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setData(String data) {
|
|
||||||
this.data = data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,7 +25,6 @@ public class AiCommonController extends BaseController
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ElevenLabsClient elevenLabsClient;
|
private ElevenLabsClient elevenLabsClient;
|
||||||
|
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private ChatGPTClient chatGPTClient;
|
private ChatGPTClient chatGPTClient;
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,6 @@ xss:
|
|||||||
# 过滤开关
|
# 过滤开关
|
||||||
enabled: true
|
enabled: true
|
||||||
# 排除链接(多个用逗号分隔)
|
# 排除链接(多个用逗号分隔)
|
||||||
excludes: /system/notice
|
excludes: /system/notice,/voice-websocket/*
|
||||||
# 匹配链接
|
# 匹配链接
|
||||||
urlPatterns: /system/*,/monitor/*,/tool/*
|
urlPatterns: /system/*,/monitor/*,/tool/*,/voice-websocket/*
|
||||||
|
|||||||
@@ -75,6 +75,6 @@ xss:
|
|||||||
# 过滤开关
|
# 过滤开关
|
||||||
enabled: true
|
enabled: true
|
||||||
# 排除链接(多个用逗号分隔)
|
# 排除链接(多个用逗号分隔)
|
||||||
excludes: /system/notice
|
excludes: /system/notice,/voice-websocket/*
|
||||||
# 匹配链接
|
# 匹配链接
|
||||||
urlPatterns: /system/*,/monitor/*,/tool/*
|
urlPatterns: /system/*,/monitor/*,/tool/*,/voice-websocket/*
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ public class SecurityConfig
|
|||||||
.authorizeHttpRequests((requests) -> {
|
.authorizeHttpRequests((requests) -> {
|
||||||
permitAllUrl.getUrls().forEach(url -> requests.antMatchers(url).permitAll());
|
permitAllUrl.getUrls().forEach(url -> requests.antMatchers(url).permitAll());
|
||||||
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
|
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
|
||||||
requests.antMatchers("/login", "/register", "/captchaImage","/aiCommon/**","/voice-websocket").permitAll()
|
requests.antMatchers("/login", "/register", "/captchaImage","/aiCommon/**","/voice-websocket/**").permitAll()
|
||||||
// 静态资源,可匿名访问
|
// 静态资源,可匿名访问
|
||||||
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
|
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
|
||||||
.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
|
.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
|
||||||
|
|||||||
Reference in New Issue
Block a user