diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index 03667d8..7883a6b 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -11,8 +11,9 @@
-
+
+
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
index 25bc32b..bb396d2 100644
--- a/.idea/encodings.xml
+++ b/.idea/encodings.xml
@@ -5,6 +5,8 @@
+
+
diff --git a/pom.xml b/pom.xml
index fe16a91..7f87c0a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -80,6 +80,13 @@
import
+
+
+ org.springframework.boot
+ spring-boot-starter-websocket
+ 2.5.15
+
+
ch.qos.logback
@@ -254,6 +261,13 @@
${vetti.version}
+
+
+ com.vetti
+ vetti-ai
+ ${vetti.version}
+
+
com.squareup.okhttp3
@@ -300,6 +314,7 @@
vetti-quartz
vetti-generator
vetti-common
+ vetti-ai
pom
diff --git a/vetti-admin/pom.xml b/vetti-admin/pom.xml
index 532d24f..6e12b32 100644
--- a/vetti-admin/pom.xml
+++ b/vetti-admin/pom.xml
@@ -25,6 +25,12 @@
true
+
+
+ org.springframework.boot
+ spring-boot-starter-websocket
+
+
io.springfox
@@ -69,7 +75,15 @@
vetti-generator
-
+
+
+ com.vetti
+ vetti-ai
+
+
+ org.springframework.boot
+ spring-boot-starter-websocket
+
diff --git a/vetti-admin/src/main/java/com/vetti/socket/VoiceHandshakeInterceptor.java b/vetti-admin/src/main/java/com/vetti/socket/VoiceHandshakeInterceptor.java
new file mode 100644
index 0000000..d2ba28e
--- /dev/null
+++ b/vetti-admin/src/main/java/com/vetti/socket/VoiceHandshakeInterceptor.java
@@ -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 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) {
+ // 握手后操作,可留空
+ }
+}
+
diff --git a/vetti-admin/src/main/java/com/vetti/socket/VoiceWebSocketHandler.java b/vetti-admin/src/main/java/com/vetti/socket/VoiceWebSocketHandler.java
new file mode 100644
index 0000000..d9e24ca
--- /dev/null
+++ b/vetti-admin/src/main/java/com/vetti/socket/VoiceWebSocketHandler.java
@@ -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> clientVoiceParts = new ConcurrentHashMap<>();
+ // 存储每个客户端的总分片数,key: clientId
+ private final Map clientTotalParts = new ConcurrentHashMap<>();
+ // 用于并发控制的锁
+ private final Map 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 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 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");
+ }
+}
diff --git a/vetti-admin/src/main/java/com/vetti/socket/WebSocketConfig.java b/vetti-admin/src/main/java/com/vetti/socket/WebSocketConfig.java
new file mode 100644
index 0000000..0b52f1c
--- /dev/null
+++ b/vetti-admin/src/main/java/com/vetti/socket/WebSocketConfig.java
@@ -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("*"); // 生产环境应指定具体域名而非*
+ }
+}
diff --git a/vetti-admin/src/main/java/com/vetti/socket/vo/VoicePartMessage.java b/vetti-admin/src/main/java/com/vetti/socket/vo/VoicePartMessage.java
new file mode 100644
index 0000000..47da4c1
--- /dev/null
+++ b/vetti-admin/src/main/java/com/vetti/socket/vo/VoicePartMessage.java
@@ -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;
+ }
+}
diff --git a/vetti-admin/src/main/resources/application-dev.yml b/vetti-admin/src/main/resources/application-dev.yml
index df0fd10..8813b7c 100644
--- a/vetti-admin/src/main/resources/application-dev.yml
+++ b/vetti-admin/src/main/resources/application-dev.yml
@@ -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
diff --git a/vetti-ai/pom.xml b/vetti-ai/pom.xml
new file mode 100644
index 0000000..573e42a
--- /dev/null
+++ b/vetti-ai/pom.xml
@@ -0,0 +1,37 @@
+
+
+
+
+ vetti-service
+ com.vetti
+ 3.9.0
+
+
+ 4.0.0
+
+ vetti-ai
+
+
+
+
+
+ io.swagger
+ swagger-models
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+ com.vetti
+ vetti-common
+
+
+
+
+
\ No newline at end of file
diff --git a/vetti-ai/src/main/java/com/vetti/ai/service/ChatCommonService.java b/vetti-ai/src/main/java/com/vetti/ai/service/ChatCommonService.java
new file mode 100644
index 0000000..d8d5f79
--- /dev/null
+++ b/vetti-ai/src/main/java/com/vetti/ai/service/ChatCommonService.java
@@ -0,0 +1,13 @@
+package com.vetti.ai.service;
+
+/**
+ * 面试聊天共通 服务层
+ */
+public interface ChatCommonService {
+
+
+ /**
+ * 处理面试聊天语音结果数据
+ */
+ public void handleChatVoiceData();
+}
diff --git a/vetti-ai/src/main/java/com/vetti/ai/service/impl/ChatCommonServiceImpl.java b/vetti-ai/src/main/java/com/vetti/ai/service/impl/ChatCommonServiceImpl.java
new file mode 100644
index 0000000..ffa634e
--- /dev/null
+++ b/vetti-ai/src/main/java/com/vetti/ai/service/impl/ChatCommonServiceImpl.java
@@ -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、返回最终的语音文件
+
+ }
+}