socket 逻辑初始化

This commit is contained in:
wangxiangshun
2025-10-04 17:47:58 +08:00
parent 51c27865f0
commit 8afa39777f
12 changed files with 391 additions and 2 deletions

3
.idea/compiler.xml generated
View File

@@ -11,8 +11,9 @@
<module name="vetti-generator" />
<module name="vetti-quartz" />
<module name="vetti-system" />
<module name="vetti-framework" />
<module name="vetti-common" />
<module name="vetti-framework" />
<module name="vetti-ai" />
</profile>
</annotationProcessing>
</component>

2
.idea/encodings.xml generated
View File

@@ -5,6 +5,8 @@
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/vetti-admin/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/vetti-admin/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/vetti-ai/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/vetti-ai/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/vetti-common/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/vetti-common/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/vetti-framework/src/main/java" charset="UTF-8" />

15
pom.xml
View File

@@ -80,6 +80,13 @@
<scope>import</scope>
</dependency>
<!-- Spring Boot WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>2.5.15</version>
</dependency>
<!-- 覆盖logback的依赖配置-->
<dependency>
<groupId>ch.qos.logback</groupId>
@@ -254,6 +261,13 @@
<version>${vetti.version}</version>
</dependency>
<!-- AI 聊天-->
<dependency>
<groupId>com.vetti</groupId>
<artifactId>vetti-ai</artifactId>
<version>${vetti.version}</version>
</dependency>
<!-- 4.x 版本,兼容 MinIO 8.x -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
@@ -300,6 +314,7 @@
<module>vetti-quartz</module>
<module>vetti-generator</module>
<module>vetti-common</module>
<module>vetti-ai</module>
</modules>
<packaging>pom</packaging>

View File

@@ -25,6 +25,12 @@
<optional>true</optional>
</dependency>
<!-- Spring Boot WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- swagger3-->
<dependency>
<groupId>io.springfox</groupId>
@@ -69,7 +75,15 @@
<artifactId>vetti-generator</artifactId>
</dependency>
<!-- AI 业务逻辑处理-->
<dependency>
<groupId>com.vetti</groupId>
<artifactId>vetti-ai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
</dependencies>

View File

@@ -0,0 +1,36 @@
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) {
// 握手后操作,可留空
}
}

View File

@@ -0,0 +1,156 @@
package com.vetti.socket;
import com.vetti.socket.vo.VoicePartMessage;
import org.springframework.stereotype.Component;
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.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;
@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 {
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
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");
}
}

View File

@@ -0,0 +1,31 @@
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("*"); // 生产环境应指定具体域名而非*
}
}

View File

@@ -0,0 +1,47 @@
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;
}
}

View File

@@ -142,11 +142,13 @@ verification:
length: 5
# 验证码过期时间(分钟)
expiration-minutes: 10
# 文本转语音
elevenLabs:
baseUrl: https://api.elevenlabs.io/v1
apiKey: sk_5240d8f56cb1eb5225fffcf903f62479884d1af5b3de6812
modelId: eleven_monolingual_v1
# 语音转文本
whisper:
apiUrl: https://api.openai.com/v1/audio/transcriptions

37
vetti-ai/pom.xml Normal file
View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>vetti-service</artifactId>
<groupId>com.vetti</groupId>
<version>3.9.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>vetti-ai</artifactId>
<dependencies>
<!-- 防止进入swagger页面报类型转换错误排除3.0.0中的引用手动增加1.6.2版本 -->
<dependency>
<groupId>io.swagger</groupId>
<artifactId>swagger-models</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- 通用工具-->
<dependency>
<groupId>com.vetti</groupId>
<artifactId>vetti-common</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,13 @@
package com.vetti.ai.service;
/**
* 面试聊天共通 服务层
*/
public interface ChatCommonService {
/**
* 处理面试聊天语音结果数据
*/
public void handleChatVoiceData();
}

View File

@@ -0,0 +1,35 @@
package com.vetti.ai.service.impl;
import com.vetti.ai.service.ChatCommonService;
import com.vetti.common.ai.elevenLabs.ElevenLabsClient;
import com.vetti.common.ai.gpt.ChatGPTClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* 聊天面试共通 服务层实现
*/
@Service
public class ChatCommonServiceImpl implements ChatCommonService {
@Autowired
private ElevenLabsClient elevenLabsClient;
@Autowired
private ChatGPTClient chatGPTClient;
@Override
public void handleChatVoiceData() {
//1、获取面试传输的语音文件
//2、语音文件转换成文本字符串
//3、把文本传输到GPT中,等待回复
//4、GPT返回的结果,文本转成语音文件
//5、返回最终的语音文件
}
}