集成ai部分
This commit is contained in:
@@ -184,3 +184,19 @@ oauth:
|
||||
connect-timeout: 10
|
||||
read-timeout: 30
|
||||
write-timeout: 30
|
||||
|
||||
#ai
|
||||
chat:
|
||||
baseUrl: http://39.98.44.136:8082
|
||||
chatUrl: /v1/chat/completions
|
||||
chatDetailUrl: /core/chat/getPaginationRecords
|
||||
chatHistoryUrl: /core/chat/getHistories
|
||||
updateNameUrl: /core/chat/updateHistory
|
||||
stickChatUrl: /core/chat/updateHistory
|
||||
delChatUrl: /core/chat/delHistory
|
||||
delAllChatUrl: /core/chat/clearHistories
|
||||
guestUrl: /v1/chat/completions
|
||||
praiseUrl: /core/chat/feedback/updateUserFeedback
|
||||
appId: 67cd49095e947ae0ca7fadd8
|
||||
apiKey: fastgpt-qMl63276wPZvKAxEkW77bur0sSJpmuC6Ngg9lzyEjufLhsBAurjT55j
|
||||
model: qd-job-turbo
|
||||
@@ -0,0 +1,296 @@
|
||||
package com.ruoyi.cms.config;
|
||||
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.cms.domain.chat.ChatRequest;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import lombok.var;
|
||||
import okhttp3.*;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Component
|
||||
public class ChatClient {
|
||||
// 超时设置(单位:秒)
|
||||
private static final int CONNECT_TIMEOUT = 30;
|
||||
private static final int WRITE_TIMEOUT = 30;
|
||||
private static final int READ_TIMEOUT = 300; // 流式响应不设置读取超时
|
||||
|
||||
// 单例 OkHttp 客户端(复用连接池)
|
||||
private static final OkHttpClient client = new OkHttpClient.Builder()
|
||||
.connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
|
||||
.writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS)
|
||||
.readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
private static final String CHAT_ENDPOINT = "chat";
|
||||
|
||||
private static final String CHAT_HISTORY = "history";
|
||||
public static final List<String> TEXT_FILE_EXTENSIONS= Arrays.asList(".txt", ".md", ".html", ".doc", ".docx", ".pdf", ".ppt", ".pptx", ".csv", ".xls", ".xlsx");
|
||||
public static final List<String> IMAGE_FILE_EXTENSIONS=Arrays.asList(".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp");
|
||||
|
||||
|
||||
private ChatConfig chatConfig;
|
||||
@Autowired
|
||||
public void setChatConfig(ChatConfig chatConfig) {
|
||||
this.chatConfig = chatConfig;
|
||||
}
|
||||
@Value("${spring.profiles.active}")
|
||||
private String env;
|
||||
/**
|
||||
* 发送流式聊天请求
|
||||
* @param chatRequest 查询请求体
|
||||
* @param callback 流式响应回调接口
|
||||
*/
|
||||
public void sendStreamingChat(ChatRequest chatRequest, StreamCallback callback) {
|
||||
String url=chatConfig.getBaseUrl()+chatConfig.getChatUrl();
|
||||
// 构建请求体
|
||||
String jsonBody = buildChatRequestBody(chatRequest, CHAT_ENDPOINT);
|
||||
// 构建请求
|
||||
Request request = null;
|
||||
|
||||
try {
|
||||
RequestBody body = RequestBody.create(MediaType.parse("application/json; charset=utf-8"),jsonBody);
|
||||
request = new Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.addHeader("Authorization", "Bearer " + chatConfig.getApiKey())
|
||||
.post(body).build();
|
||||
}catch (Exception e){
|
||||
e.printStackTrace();
|
||||
callback.onError(new RuntimeException("构建请求失败: " + e.getMessage(), e));
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送异步请求
|
||||
client.newCall(request).enqueue(new Callback() {
|
||||
@Override
|
||||
public void onFailure(Call call, IOException e) {
|
||||
callback.onError(new RuntimeException("请求发送失败: " + e.getMessage(), e));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(Call call, Response response) throws IOException {
|
||||
try {
|
||||
if (!response.isSuccessful()) {
|
||||
String errorBody = response.body() != null ? response.body().string() : "无错误信息";
|
||||
String errorMsg = String.format("API 响应错误: 状态码=%d, 错误信息=%s",
|
||||
response.code(), errorBody);
|
||||
System.err.println(errorMsg); // 打印详细错误
|
||||
callback.onError(new RuntimeException(errorMsg));
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理流式响应
|
||||
ResponseBody body = response.body();
|
||||
if (body == null) {
|
||||
callback.onError(new RuntimeException("响应体为空"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 逐行读取 SSE 格式的响应
|
||||
try (var bufferedSource = body.source()) {
|
||||
while (!bufferedSource.exhausted()) {
|
||||
String chunk = bufferedSource.readUtf8Line();
|
||||
if (chunk != null && !chunk.trim().isEmpty()) {
|
||||
callback.onData(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通知流结束
|
||||
callback.onComplete();
|
||||
|
||||
} catch (Exception e) {
|
||||
String errorMsg = "处理响应失败: " + e.getMessage();
|
||||
System.err.println(errorMsg);
|
||||
callback.onError(new RuntimeException(errorMsg, e));
|
||||
} finally {
|
||||
response.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建聊天请求的 JSON 体
|
||||
*/
|
||||
/**
|
||||
* 构建聊天请求的 JSON 体
|
||||
* 根据文档 [cite: 162-172] 修改为 Vision API 兼容格式
|
||||
*/
|
||||
private String buildChatRequestBody(ChatRequest chatRequest, String key) {
|
||||
JSONObject chatObject = new JSONObject();
|
||||
|
||||
// 1. 设置会话ID (如果有)
|
||||
if(StringUtils.isNotEmpty(chatRequest.getSessionId())){
|
||||
chatObject.put("chatId", chatRequest.getSessionId());
|
||||
}
|
||||
|
||||
if("chat".equals(key)){
|
||||
// 基础参数设置
|
||||
chatObject.put("stream", true);
|
||||
chatObject.put("model", chatConfig.getModel()); // 需确保为 "qd-job-turbo"
|
||||
chatObject.put("user", chatRequest.getSessionId());
|
||||
|
||||
// 2. 获取历史消息列表,如果为空则初始化
|
||||
JSONArray messages = chatRequest.getMessages();
|
||||
if (messages == null) {
|
||||
messages = new JSONArray();
|
||||
}
|
||||
|
||||
// 3. 构建当前用户的多模态消息内容 (Multimodal Content) [cite: 162]
|
||||
JSONArray contentArray = new JSONArray();
|
||||
|
||||
// 3.1 添加文本内容 [cite: 166, 172]
|
||||
if(StringUtils.isNotEmpty(chatRequest.getData())){
|
||||
JSONObject textPart = new JSONObject();
|
||||
textPart.put("type", "text");
|
||||
textPart.put("text", chatRequest.getData());
|
||||
contentArray.add(textPart);
|
||||
}
|
||||
|
||||
// 3.2 添加文件内容 (图片、PDF、Excel等) [cite: 174, 180, 232]
|
||||
// 文档说明:image_url 字段兼容 PDF, Excel, PPT 等所有 OCR 支持的文件
|
||||
if(!CollectionUtils.isEmpty(chatRequest.getFileUrl())){
|
||||
for(String url : chatRequest.getFileUrl()){
|
||||
String finalUrl = "";
|
||||
if(Objects.equals(env, "pro")){
|
||||
finalUrl = url.replace("https://fw.rc.qingdao.gov.cn/rgpp-api/api/ng", "http://10.213.6.207:19010");
|
||||
}else {
|
||||
finalUrl = url;
|
||||
}
|
||||
// 处理内网/外网地址映射 (保留你原有的逻辑)
|
||||
|
||||
JSONObject filePart = new JSONObject();
|
||||
filePart.put("type", "image_url"); // 固定为 image_url [cite: 174]
|
||||
|
||||
JSONObject imageUrlObj = new JSONObject();
|
||||
imageUrlObj.put("url", finalUrl); // 文件地址 [cite: 172]
|
||||
|
||||
filePart.put("image_url", imageUrlObj);
|
||||
contentArray.add(filePart);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 将当前消息封装为 User Message 对象并加入消息列表 [cite: 151, 163]
|
||||
// 只有当有内容(文本或文件)时才添加
|
||||
if (!contentArray.isEmpty()) {
|
||||
JSONObject currentUserMessage = new JSONObject();
|
||||
currentUserMessage.put("role", "user");
|
||||
currentUserMessage.put("content", contentArray); // content 为数组格式
|
||||
messages.add(currentUserMessage);
|
||||
}
|
||||
|
||||
// 5. 将完整的消息列表放入请求体 [cite: 135]
|
||||
chatObject.put("messages", messages);
|
||||
|
||||
} else {
|
||||
// 非 chat 场景的逻辑保留
|
||||
chatObject.put("appId", chatConfig.getAppId());
|
||||
}
|
||||
|
||||
return chatObject.toJSONString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的 JSON 转义处理(防止特殊字符破坏 JSON 格式)
|
||||
*/
|
||||
private String escapeJson(String value) {
|
||||
if (value == null) return "";
|
||||
return value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\b", "\\b")
|
||||
.replace("\f", "\\f")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t");
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天请求并返回完整JSON响应
|
||||
* @param chatRequest 用户输入的查询内容
|
||||
* @return 完整的JSON响应字符串
|
||||
* @throws IOException 网络请求异常
|
||||
*/
|
||||
public String sendChatGuest(ChatRequest chatRequest) throws IOException {
|
||||
String url=chatConfig.getBaseUrl()+chatConfig.getGuestUrl();
|
||||
|
||||
JSONArray array = chatRequest.getMessages();
|
||||
if(array==null||array.isEmpty()||array.size()==0){
|
||||
array = new JSONArray();
|
||||
}
|
||||
JSONObject contentObject = new JSONObject();
|
||||
contentObject.put("content","你是一个岗位招聘专家,请根据用户的问题生成用户下一步想要提出的问题。需要以用户的口吻进行生成。结合上下文中用户提出的问题以及助手回复的答案,需要猜测用户更进一步的需求,例如期望的薪资,期望的工作地点,掌握的技能。生成的问题举例:有没有薪资在9000以上的工作?我的学历是本科。我希望找国企。注意不仅限于这些,还要根据上下文。其次所有的问题应该限定在青岛。并且只生成3到4个。");
|
||||
contentObject.put("role","system");
|
||||
array.add(contentObject);
|
||||
JSONObject jsonBody = new JSONObject();
|
||||
jsonBody.put("stream",false);
|
||||
jsonBody.put("model",chatConfig.getModel());
|
||||
jsonBody.put("messages",array);
|
||||
|
||||
// 构建请求(使用非流式响应模式)
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), String.valueOf(jsonBody));
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.addHeader("Authorization", "Bearer " + chatConfig.getApiKey())
|
||||
.post(requestBody).build();
|
||||
|
||||
// 发送同步请求
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (!response.isSuccessful()) {
|
||||
String errorBody = response.body() != null ? response.body().string() : "无错误信息";
|
||||
String errorMsg = String.format("API 响应错误: 状态码=%d, 错误信息=%s",
|
||||
response.code(), errorBody);
|
||||
throw new IOException(errorMsg);
|
||||
}
|
||||
ResponseBody body = response.body();
|
||||
if (body == null) {
|
||||
throw new IOException("响应体为空");
|
||||
}
|
||||
JSONObject object = JSONObject.parseObject(body.string());
|
||||
String choices = object.getString("choices");
|
||||
if (choices != null && !choices.trim().isEmpty()) {
|
||||
JSONArray jsonArray = JSONArray.parseArray(choices);
|
||||
object = JSONObject.parseObject(jsonArray.getString(0));
|
||||
object = object.getJSONObject("message");
|
||||
String content = object.getString("content");// 消息内容
|
||||
|
||||
return content;
|
||||
}
|
||||
return body.string();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式响应回调接口
|
||||
*/
|
||||
public interface StreamCallback {
|
||||
/**
|
||||
* 接收分片数据
|
||||
* @param chunk SSE 格式的分片数据
|
||||
*/
|
||||
void onData(String chunk);
|
||||
|
||||
/**
|
||||
* 响应结束
|
||||
*/
|
||||
void onComplete();
|
||||
|
||||
/**
|
||||
* 发生错误
|
||||
* @param e 异常信息
|
||||
*/
|
||||
void onError(Throwable e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.ruoyi.cms.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "chat")
|
||||
public class ChatConfig {
|
||||
private String baseUrl;
|
||||
private String chatUrl;
|
||||
private String chatDetailUrl;
|
||||
private String chatHistoryUrl;
|
||||
private String updateNameUrl;
|
||||
private String delChatUrl;
|
||||
private String delAllChatUrl;
|
||||
private String guestUrl;
|
||||
private String praiseUrl;
|
||||
private String apiKey;
|
||||
private String appId;
|
||||
private String model;
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
public void setBaseUrl(String baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
public String getChatUrl() {
|
||||
return chatUrl;
|
||||
}
|
||||
|
||||
public void setChatUrl(String chatUrl) {
|
||||
this.chatUrl = chatUrl;
|
||||
}
|
||||
|
||||
public String getChatDetailUrl() {
|
||||
return chatDetailUrl;
|
||||
}
|
||||
|
||||
public void setChatDetailUrl(String chatDetailUrl) {
|
||||
this.chatDetailUrl = chatDetailUrl;
|
||||
}
|
||||
|
||||
public String getChatHistoryUrl() {
|
||||
return chatHistoryUrl;
|
||||
}
|
||||
|
||||
public void setChatHistoryUrl(String chatHistoryUrl) {
|
||||
this.chatHistoryUrl = chatHistoryUrl;
|
||||
}
|
||||
|
||||
public String getUpdateNameUrl() {
|
||||
return updateNameUrl;
|
||||
}
|
||||
|
||||
public void setUpdateNameUrl(String updateNameUrl) {
|
||||
this.updateNameUrl = updateNameUrl;
|
||||
}
|
||||
|
||||
public String getDelChatUrl() {
|
||||
return delChatUrl;
|
||||
}
|
||||
|
||||
public void setDelChatUrl(String delChatUrl) {
|
||||
this.delChatUrl = delChatUrl;
|
||||
}
|
||||
|
||||
public String getDelAllChatUrl() {
|
||||
return delAllChatUrl;
|
||||
}
|
||||
|
||||
public void setDelAllChatUrl(String delAllChatUrl) {
|
||||
this.delAllChatUrl = delAllChatUrl;
|
||||
}
|
||||
|
||||
public String getGuestUrl() {
|
||||
return guestUrl;
|
||||
}
|
||||
|
||||
public void setGuestUrl(String guestUrl) {
|
||||
this.guestUrl = guestUrl;
|
||||
}
|
||||
|
||||
public String getPraiseUrl() {
|
||||
return praiseUrl;
|
||||
}
|
||||
|
||||
public void setPraiseUrl(String praiseUrl) {
|
||||
this.praiseUrl = praiseUrl;
|
||||
}
|
||||
|
||||
public String getApiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
public void setApiKey(String apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
public String getAppId() {
|
||||
return appId;
|
||||
}
|
||||
|
||||
public void setAppId(String appId) {
|
||||
this.appId = appId;
|
||||
}
|
||||
public String getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
public void setModel(String model) {
|
||||
this.model = model;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package com.ruoyi.cms.controller.app;
|
||||
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.cms.domain.ai.AiChatHistory;
|
||||
import com.ruoyi.cms.domain.chat.ChatRequest;
|
||||
import com.ruoyi.cms.service.AiChatHistoryService;
|
||||
import com.ruoyi.common.annotation.BussinessLog;
|
||||
import com.ruoyi.cms.config.ChatClient;
|
||||
import com.ruoyi.cms.config.ChatConfig;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.SiteSecurityUtils;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import javax.annotation.PreDestroy;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static com.ruoyi.common.enums.BusinessType.QUERY;
|
||||
|
||||
@RestController
|
||||
@RequestMapping(value = "/app/chat")
|
||||
@Slf4j
|
||||
@Api(tags = "移动端:ai对话")
|
||||
public class ChatController extends BaseController {
|
||||
|
||||
@Autowired
|
||||
ChatClient chatClient;
|
||||
@Autowired
|
||||
ChatConfig chatConfig;
|
||||
@Autowired
|
||||
AiChatHistoryService aiChatHistoryService;
|
||||
|
||||
//private final ExecutorService executor = Executors.newCachedThreadPool();
|
||||
// 可优化线程池配置,避免无限创建线程
|
||||
private final ExecutorService executor = new ThreadPoolExecutor(
|
||||
2, // 核心线程数
|
||||
10, // 最大线程数
|
||||
30, TimeUnit.SECONDS,
|
||||
new LinkedBlockingQueue<>(50), // 任务队列
|
||||
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略(避免任务丢失)
|
||||
);
|
||||
|
||||
@BussinessLog(title = "查询用户的聊天历史记录",businessType = QUERY)
|
||||
@ApiOperation("查询用户的聊天历史记录")
|
||||
@GetMapping(value = "/getHistory")
|
||||
public AjaxResult getChatHistoryList(AiChatHistory chatHistory) {
|
||||
return AjaxResult.success(aiChatHistoryService.getList(chatHistory));
|
||||
}
|
||||
|
||||
@BussinessLog(title = "查询用户的聊天详情",businessType = QUERY)
|
||||
@ApiOperation("查询用户的聊天详情")
|
||||
@GetMapping(value = "/detail")
|
||||
public AjaxResult getChatDetail(ChatRequest request) {
|
||||
return AjaxResult.success(aiChatHistoryService.getDetailList(request.getSessionId()));
|
||||
}
|
||||
|
||||
// 处理前端聊天请求,返回 SSE 发射器
|
||||
@ApiOperation("用户AI聊天")
|
||||
@PostMapping("/chat")
|
||||
@ResponseBody
|
||||
@BussinessLog(title = "用户AI聊天",businessType = QUERY)
|
||||
public SseEmitter chatStream(@RequestBody ChatRequest request) {
|
||||
// 设置超时时间(30分钟)
|
||||
SseEmitter emitter = new SseEmitter(1800000L);
|
||||
long userId = 0;
|
||||
if(ObjectUtil.isNotEmpty(request.getUserId())&&request.getUserId()!=0){
|
||||
userId = request.getUserId();
|
||||
}else{
|
||||
try {
|
||||
userId = SiteSecurityUtils.getLoginUser().getUserId();
|
||||
}catch (Exception e) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject contentObject = new JSONObject();
|
||||
contentObject.put("content",request.getData());
|
||||
contentObject.put("role","user");
|
||||
JSONArray array = aiChatHistoryService.getChatHistoryData(request.getSessionId());
|
||||
if(array==null||array.isEmpty()||array.size()==0){
|
||||
array = new JSONArray();
|
||||
}
|
||||
array.add(contentObject);
|
||||
request.setMessages(array);
|
||||
|
||||
List<String> answerList = new ArrayList<>();
|
||||
long[] timeStart = {0};
|
||||
// 异步处理请求并推送数据
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
// 2. 调用ChatClient的流式聊天方法,正确传递参数
|
||||
chatClient.sendStreamingChat(
|
||||
request,
|
||||
new ChatClient.StreamCallback() { // 使用内部类完整路径
|
||||
@Override
|
||||
public void onData(String chunk) {
|
||||
try {
|
||||
if(timeStart[0] == 0){
|
||||
timeStart[0] = System.currentTimeMillis();
|
||||
}
|
||||
// 1. 处理SSE协议格式
|
||||
String processedChunk = chunk.trim();
|
||||
// 解析返回的chunk数据
|
||||
String content = parseChatChunk(processedChunk);
|
||||
if (StringUtils.isNotEmpty(content)) {
|
||||
answerList.add(content);
|
||||
}
|
||||
emitter.send(processedChunk, MediaType.TEXT_EVENT_STREAM);
|
||||
} catch (IOException e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
emitter.complete();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable e) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.data("{\"status\":\"error\",\"message\":\"" + e.getMessage() + "\"}")
|
||||
.name("error"));
|
||||
} catch (IOException ex) {
|
||||
// 记录日志
|
||||
} finally {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (Exception e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
|
||||
// 处理连接关闭
|
||||
long finalUserId = userId;
|
||||
emitter.onCompletion(() -> {
|
||||
// 此处仅做资源清理,不关闭线程池
|
||||
log.info("连接关闭,清理资源");
|
||||
long timeEnd = System.currentTimeMillis();
|
||||
double duration = (timeEnd - timeStart[0]) / 1000.0;
|
||||
AiChatHistory chatHistory = new AiChatHistory();
|
||||
chatHistory.setChatId(request.getSessionId());
|
||||
chatHistory.setUserId((finalUserId==0)?null:finalUserId);
|
||||
chatHistory.setAppId(chatConfig.getAppId());
|
||||
chatHistory.setDataId(request.getDataId());
|
||||
chatHistory.setTitle(request.getData());
|
||||
chatHistory.setAnswerStringList(answerList);
|
||||
chatHistory.setDurationSeconds(duration);
|
||||
aiChatHistoryService.saveChatHistory(chatHistory);
|
||||
});
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@BussinessLog(title = "提供推测出的问询建议",businessType = QUERY)
|
||||
@ApiOperation("提供推测出的问询建议")
|
||||
@PostMapping(value = "/guest")
|
||||
public AjaxResult getChatGuest(@RequestBody ChatRequest request) throws IOException {
|
||||
JSONArray array = aiChatHistoryService.getChatHistoryData(request.getSessionId());
|
||||
request.setMessages(array);
|
||||
String result = chatClient.sendChatGuest(request);
|
||||
try {
|
||||
String[] strList = result.split("?");
|
||||
List<String> list = new ArrayList<>();
|
||||
for(String str:strList){
|
||||
if(StringUtils.isNotEmpty(str)){
|
||||
str = str+"?";
|
||||
list.add(str);
|
||||
}
|
||||
}
|
||||
return AjaxResult.success(list);
|
||||
}catch (Exception e) {
|
||||
return AjaxResult.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 解析返回的流式数据
|
||||
private String parseChatChunk(String chunk) {
|
||||
try {
|
||||
String processed = chunk.trim();
|
||||
if (processed.startsWith("data:")) {
|
||||
processed = processed.substring("data:".length()).trim();
|
||||
}
|
||||
if (processed.isEmpty() || "[DONE]".equals(processed)) {
|
||||
return null;
|
||||
}
|
||||
JSONObject json = JSONObject.parseObject(processed);
|
||||
String choices = json.getString("choices");
|
||||
if (choices != null && !choices.trim().isEmpty()) {
|
||||
JSONArray jsonArray = JSONArray.parseArray(choices);
|
||||
json = JSONObject.parseObject(jsonArray.getString(0));
|
||||
json = json.getJSONObject("delta");
|
||||
String content = json.getString("content");// 消息内容
|
||||
|
||||
return content;
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
executor.shutdown(); // 应用退出前关闭线程池
|
||||
try {
|
||||
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
executor.shutdownNow(); // 强制关闭未完成的任务
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
executor.shutdownNow();
|
||||
log.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ruoyi.cms.domain.ai;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@ApiModel("ai聊天详情")
|
||||
@TableName("AI_CHAT_DETAIL")
|
||||
public class AiChatDetail {
|
||||
|
||||
@TableField(exist = false)
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id",type = IdType.AUTO)
|
||||
@ApiModelProperty("id")
|
||||
private Long id;
|
||||
|
||||
@ApiModelProperty("会话id")
|
||||
private String chatId;
|
||||
@ApiModelProperty("数据id")
|
||||
private String dataId;
|
||||
@ApiModelProperty("会话类型,Human:用户,AI:大模型")
|
||||
private String obj;
|
||||
@ApiModelProperty("会话内容")
|
||||
private String content;
|
||||
@ApiModelProperty("会话时间")
|
||||
private Date time;
|
||||
@ApiModelProperty("耗时")
|
||||
private Double durationSeconds;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.ruoyi.cms.domain.ai;
|
||||
|
||||
import com.alibaba.fastjson2.annotation.JSONField;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@ApiModel("ai聊天历史记录")
|
||||
@TableName("AI_CHAT_HISTORY")
|
||||
public class AiChatHistory{
|
||||
|
||||
@TableField(exist = false)
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id",type = IdType.AUTO)
|
||||
@ApiModelProperty("id")
|
||||
private Long id;
|
||||
@ApiModelProperty("用户id")
|
||||
private Long userId;
|
||||
@JSONField(name = "chatId")
|
||||
@ApiModelProperty("会话id")
|
||||
private String chatId;
|
||||
@ApiModelProperty("应用id")
|
||||
private String appId;
|
||||
@JSONField(name = "title")
|
||||
@ApiModelProperty("第一次的问题")
|
||||
private String title;
|
||||
@JSONField(name = "updateTime")
|
||||
@ApiModelProperty("会话时间")
|
||||
private Date updateTime;
|
||||
@ApiModelProperty("是否删除")
|
||||
private String delFlag;
|
||||
|
||||
@TableField(exist = false)
|
||||
private List<String> answerStringList;
|
||||
@TableField(exist = false)
|
||||
private double durationSeconds;
|
||||
@TableField(exist = false)
|
||||
private String customTitle;
|
||||
@TableField(exist = false)
|
||||
private String dataId;
|
||||
@TableField(exist = false)
|
||||
private boolean top=false;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ruoyi.cms.domain.chat;
|
||||
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class ChatRequest {
|
||||
private String data;
|
||||
private String dataId;
|
||||
private String sessionId;
|
||||
private long userId;
|
||||
private List<String> fileUrl;
|
||||
|
||||
private JSONArray messages;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.ruoyi.cms.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.cms.domain.ai.AiChatDetail;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface AiChatDetailMapper extends BaseMapper<AiChatDetail> {
|
||||
|
||||
List<AiChatDetail> getList(AiChatDetail aiChatDetail);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.ruoyi.cms.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.cms.domain.ai.AiChatHistory;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface AiChatHistoryMapper extends BaseMapper<AiChatHistory> {
|
||||
|
||||
List<AiChatHistory> getList(AiChatHistory aiChatHistory);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.ruoyi.cms.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.ruoyi.cms.domain.ai.AiChatHistory;
|
||||
|
||||
public interface AiChatHistoryService extends IService<AiChatHistory> {
|
||||
|
||||
JSONObject getList(AiChatHistory aiChatHistory);
|
||||
|
||||
void saveChatHistory(AiChatHistory aiChatHistory);
|
||||
|
||||
JSONObject getDetailList(String chatId);
|
||||
|
||||
JSONArray getChatHistoryData(String chatId);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.ruoyi.cms.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.ruoyi.cms.domain.ai.AiChatDetail;
|
||||
import com.ruoyi.cms.domain.ai.AiChatHistory;
|
||||
import com.ruoyi.cms.mapper.AiChatDetailMapper;
|
||||
import com.ruoyi.cms.mapper.AiChatHistoryMapper;
|
||||
import com.ruoyi.cms.service.AiChatHistoryService;
|
||||
import com.ruoyi.common.utils.SiteSecurityUtils;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.utils.uuid.IdUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class AiChatHistoryServiceImpl extends ServiceImpl<AiChatHistoryMapper, AiChatHistory> implements AiChatHistoryService {
|
||||
|
||||
@Autowired
|
||||
private AiChatHistoryMapper aiChatHistoryMapper;
|
||||
@Autowired
|
||||
private AiChatDetailMapper aiChatDetailMapper;
|
||||
|
||||
@Override
|
||||
public JSONObject getList(AiChatHistory aiChatHistory) {
|
||||
JSONObject object = new JSONObject();
|
||||
JSONArray jsonArray = new JSONArray();
|
||||
if(Objects.isNull(aiChatHistory.getUserId())){
|
||||
try {
|
||||
aiChatHistory.setUserId(SiteSecurityUtils.getUserId());
|
||||
}catch (Exception e){
|
||||
object.put("list", jsonArray);
|
||||
return object;
|
||||
}
|
||||
}
|
||||
List<AiChatHistory> list = aiChatHistoryMapper.getList(aiChatHistory);
|
||||
if(list!=null&&!list.isEmpty()){
|
||||
JSONObject jsonObject1 = new JSONObject();
|
||||
for(AiChatHistory history:list){
|
||||
jsonObject1 = JSONObject.parseObject(JSONObject.toJSONString(history));
|
||||
jsonArray.add(jsonObject1);
|
||||
}
|
||||
}
|
||||
object.put("list", jsonArray);
|
||||
return object;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveChatHistory(AiChatHistory aiChatHistory) {
|
||||
try {
|
||||
AiChatHistory history = new AiChatHistory();
|
||||
history.setChatId(aiChatHistory.getChatId());
|
||||
List<AiChatHistory> list = aiChatHistoryMapper.getList(history);
|
||||
if(list!=null&&!list.isEmpty()){
|
||||
history = list.get(0);
|
||||
history.setUpdateTime(new Date());
|
||||
aiChatHistoryMapper.updateById(history);
|
||||
}else{
|
||||
history.setTitle(aiChatHistory.getTitle());
|
||||
history.setUserId(aiChatHistory.getUserId());
|
||||
history.setAppId(aiChatHistory.getAppId());
|
||||
history.setDelFlag("0");
|
||||
history.setUpdateTime(new Date());
|
||||
aiChatHistoryMapper.insert(history);
|
||||
}
|
||||
AiChatDetail chatDetail = new AiChatDetail();
|
||||
chatDetail.setChatId(aiChatHistory.getChatId());
|
||||
chatDetail.setObj("Human");
|
||||
chatDetail.setTime(new Date());
|
||||
chatDetail.setDataId(IdUtils.fastSimpleUUID());
|
||||
chatDetail.setContent(aiChatHistory.getTitle());
|
||||
aiChatDetailMapper.insert(chatDetail);
|
||||
List<String> answerList = aiChatHistory.getAnswerStringList();
|
||||
if(answerList!=null&&!answerList.isEmpty()){
|
||||
chatDetail = new AiChatDetail();
|
||||
chatDetail.setChatId(aiChatHistory.getChatId());
|
||||
chatDetail.setObj("AI");
|
||||
chatDetail.setTime(new Date());
|
||||
chatDetail.setDataId(aiChatHistory.getDataId());
|
||||
chatDetail.setContent(String.join("",answerList));
|
||||
chatDetail.setDurationSeconds(aiChatHistory.getDurationSeconds());
|
||||
aiChatDetailMapper.insert(chatDetail);
|
||||
}
|
||||
}catch (Exception e){
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject getDetailList(String chatId) {
|
||||
JSONObject dataList = new JSONObject();
|
||||
JSONArray array = new JSONArray();
|
||||
AiChatDetail detail = new AiChatDetail();
|
||||
detail.setChatId(chatId);
|
||||
List<AiChatDetail> list = aiChatDetailMapper.getList(detail);
|
||||
if(list!=null&&!list.isEmpty()){
|
||||
JSONObject data = new JSONObject();
|
||||
JSONArray value = new JSONArray();
|
||||
JSONObject valueObject = new JSONObject();
|
||||
String dataId = "";
|
||||
List<String> contentList = new ArrayList<>();
|
||||
List<AiChatDetail> details = new ArrayList<>();
|
||||
for(AiChatDetail detail1:list){
|
||||
details = list.stream().filter(fi->fi.getDataId().equals(detail1.getDataId())).collect(Collectors.toList());
|
||||
if(details.size() > 1){
|
||||
contentList = new ArrayList<>();
|
||||
if(StringUtils.isNotEmpty(dataId)){
|
||||
continue;
|
||||
}
|
||||
data = new JSONObject();
|
||||
data.put("dataId", detail1.getDataId());
|
||||
data.put("obj", detail1.getObj());
|
||||
data.put("hideInUI",false);
|
||||
data.put("customFeedbacks",new JSONArray());
|
||||
data.put("time",detail1.getTime());
|
||||
data.put("durationSeconds",detail1.getDurationSeconds());
|
||||
value = new JSONArray();
|
||||
dataId = detail1.getDataId();
|
||||
for(AiChatDetail detail2:details){
|
||||
if(detail2.getContent().contains("```")){
|
||||
contentList.add(detail2.getContent());
|
||||
}else{
|
||||
if(contentList!=null&&!contentList.isEmpty()){
|
||||
valueObject = new JSONObject();
|
||||
valueObject.put("type","text");
|
||||
valueObject.put("text",new JSONObject().fluentPut("content", String.join("",contentList)));
|
||||
value.add(valueObject);
|
||||
}
|
||||
|
||||
valueObject = new JSONObject();
|
||||
valueObject.put("type","text");
|
||||
valueObject.put("text",new JSONObject().fluentPut("content", detail2.getContent()));
|
||||
value.add(valueObject);
|
||||
}
|
||||
}
|
||||
data.put("value",value);
|
||||
array.add(data);
|
||||
}else{
|
||||
dataId = "";
|
||||
data = new JSONObject();
|
||||
data.put("dataId", detail1.getDataId());
|
||||
data.put("obj", detail1.getObj());
|
||||
data.put("hideInUI",false);
|
||||
data.put("customFeedbacks",new JSONArray());
|
||||
data.put("time",detail1.getTime());
|
||||
if("AI".equals(detail1.getObj())){
|
||||
data.put("durationSeconds",detail1.getDurationSeconds());
|
||||
}
|
||||
valueObject = new JSONObject();
|
||||
valueObject.put("type","text");
|
||||
valueObject.put("text",new JSONObject().fluentPut("content", detail1.getContent()));
|
||||
value = new JSONArray();
|
||||
value.add(valueObject);
|
||||
data.put("value",value);
|
||||
array.add(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
dataList.put("list",array);
|
||||
return dataList;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONArray getChatHistoryData(String chatId) {
|
||||
JSONObject content = null;
|
||||
JSONArray data = new JSONArray();
|
||||
AiChatDetail detail = new AiChatDetail();
|
||||
detail.setChatId(chatId);
|
||||
List<AiChatDetail> list = aiChatDetailMapper.getList(detail);
|
||||
if(list!=null&&!list.isEmpty()){
|
||||
for(AiChatDetail d:list){
|
||||
content = new JSONObject();
|
||||
if("AI".equals(d.getObj())){
|
||||
content.put("role","assistant");
|
||||
}else{
|
||||
content.put("role","user");
|
||||
}
|
||||
content.put("content",d.getContent());
|
||||
data.add(content);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.cms.mapper.AiChatDetailMapper">
|
||||
|
||||
<select id="getList" parameterType="com.ruoyi.cms.domain.ai.AiChatDetail" resultType="com.ruoyi.cms.domain.ai.AiChatDetail">
|
||||
select * from ai_chat_detail where chat_id=#{chatId} order by time
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.cms.mapper.AiChatHistoryMapper">
|
||||
|
||||
<select id="getList" parameterType="com.ruoyi.cms.domain.ai.AiChatHistory" resultType="com.ruoyi.cms.domain.ai.AiChatHistory">
|
||||
select * from ai_chat_history where del_flag=0
|
||||
<if test="userId != null and userId != ''">
|
||||
and user_id = #{userId}
|
||||
</if>
|
||||
<if test="chatId != null and chatId != ''">
|
||||
and chat_id = #{chatId}
|
||||
</if>
|
||||
<if test="appId != null and appId != ''">
|
||||
and app_id = #{appId}
|
||||
</if>
|
||||
<if test="title != null and title != ''">
|
||||
and title like CONCAT('%',#{title},'%')
|
||||
</if>
|
||||
<if test="updateTime != null">
|
||||
and to_char(update_time,'yyyy-mm-dd') = to_char(#{updateTime},'yyyy-mm-dd')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -120,6 +120,22 @@
|
||||
<artifactId>UserAgentUtils</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!--奇安信密码机-->
|
||||
<!--<dependency>
|
||||
<groupId>org.quickssl</groupId>
|
||||
<artifactId>quickapi-client-java</artifactId>
|
||||
<version>1.5.11-SNAPSHOT</version>
|
||||
<classifier>shaded</classifier>
|
||||
<scope>compile</scope>
|
||||
</dependency>-->
|
||||
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk15on</artifactId>
|
||||
<version>1.59</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- servlet包 -->
|
||||
<dependency>
|
||||
<groupId>javax.servlet</groupId>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.ruoyi.common.constant;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 加解密配置常量
|
||||
*/
|
||||
public class EncryptConstants {
|
||||
|
||||
/**
|
||||
* 是否开启加解密功能
|
||||
*/
|
||||
public static final boolean ENCRYPT_ENABLED = true;
|
||||
|
||||
/**
|
||||
* 需要加解密的URL路径模式
|
||||
*/
|
||||
public static final List<String> URL_PATTERNS = Arrays.asList(
|
||||
/*"/app/login",
|
||||
"/app/user/resume",
|
||||
"/app/user/experience/edit",
|
||||
"/app/user/experience/delete",
|
||||
"/app/user/experience/getSingle/*",
|
||||
"/app/user/experience/list",
|
||||
"/login",
|
||||
"/system/user/resetPwd",
|
||||
"/system/user/list",
|
||||
"/system/user",
|
||||
"/cms/appUser/list",
|
||||
"/cms/appUser/getResumeList",
|
||||
"/cms/appUser/getResumeDetail/*",
|
||||
"/app/alipay/scanLogin",
|
||||
"/app/user/cert"*/
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ruoyi.common.constant;
|
||||
|
||||
public class SM4Constants {
|
||||
|
||||
public static final String SM4_KET = "86C63180C1306ABC4D8F989E0A0BC9F3";
|
||||
}
|
||||
@@ -56,4 +56,8 @@ public enum BusinessType
|
||||
* 清空数据
|
||||
*/
|
||||
CLEAN,
|
||||
/**
|
||||
* 查询
|
||||
*/
|
||||
QUERY
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.ruoyi.common.filter;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.common.constant.EncryptConstants;
|
||||
import com.ruoyi.common.constant.SM4Constants;
|
||||
import com.ruoyi.common.utils.EncryptHttpServletResponseWrapper;
|
||||
import com.ruoyi.common.utils.SM4Utils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
import org.springframework.util.PathMatcher;
|
||||
|
||||
import javax.servlet.*;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class EncryptResponseFilter implements Filter {
|
||||
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final PathMatcher pathMatcher = new AntPathMatcher();
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
|
||||
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
||||
HttpServletResponse httpResponse = (HttpServletResponse) response;
|
||||
|
||||
// 检查是否开启加解密功能
|
||||
if (!EncryptConstants.ENCRYPT_ENABLED) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是SSE流式响应 - 不处理
|
||||
if (isSseResponse(httpRequest, httpResponse)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查URL是否需要加密响应
|
||||
if (!needProcess(httpRequest)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 包装响应
|
||||
EncryptHttpServletResponseWrapper responseWrapper = new EncryptHttpServletResponseWrapper(httpResponse);
|
||||
chain.doFilter(request, responseWrapper);
|
||||
|
||||
// 获取原始响应内容
|
||||
byte[] content = responseWrapper.getContent();
|
||||
String originalResponse = new String(content, StandardCharsets.UTF_8);
|
||||
|
||||
// 加密响应内容
|
||||
if (StringUtils.isNotBlank(originalResponse) && !originalResponse.trim().isEmpty()) {
|
||||
String encryptedResponse = SM4Utils.encryptEcb(SM4Constants.SM4_KET, originalResponse);
|
||||
|
||||
// 构建加密响应
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("encrypted", true);
|
||||
result.put("encryptedData", encryptedResponse);
|
||||
result.put("timestamp", System.currentTimeMillis());
|
||||
|
||||
String finalResponse = objectMapper.writeValueAsString(result);
|
||||
|
||||
// 设置响应
|
||||
byte[] encryptedBytes = finalResponse.getBytes(StandardCharsets.UTF_8);
|
||||
httpResponse.setContentLength(encryptedBytes.length);
|
||||
httpResponse.setContentType("application/json;charset=UTF-8");
|
||||
httpResponse.getOutputStream().write(encryptedBytes);
|
||||
} else {
|
||||
// 空响应直接返回空
|
||||
httpResponse.getOutputStream().write(new byte[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断请求是否需要处理
|
||||
*/
|
||||
private boolean needProcess(HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
|
||||
// 检查URL是否匹配需要加解密的模式
|
||||
for (String pattern : EncryptConstants.URL_PATTERNS) {
|
||||
if (pathMatcher.match(pattern, requestURI)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是SSE流式响应
|
||||
*/
|
||||
private boolean isSseResponse(HttpServletRequest request, HttpServletResponse response) {
|
||||
String path = request.getRequestURI();
|
||||
String method = request.getMethod();
|
||||
|
||||
// 检查是否是聊天接口
|
||||
if ("/app/chat/chat".equals(path) && "POST".equalsIgnoreCase(method)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查Accept头是否包含text/event-stream
|
||||
String acceptHeader = request.getHeader("Accept");
|
||||
if (acceptHeader != null && acceptHeader.contains("text/event-stream")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查响应头是否已经设置为text/event-stream
|
||||
String contentType = response.getContentType();
|
||||
if (contentType != null && contentType.contains("text/event-stream")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package com.ruoyi.common.filter;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.common.constant.EncryptConstants;
|
||||
import com.ruoyi.common.constant.SM4Constants;
|
||||
import com.ruoyi.common.utils.EncryptHttpServletRequestWrapper;
|
||||
import com.ruoyi.common.utils.EncryptHttpServletResponseWrapper;
|
||||
import com.ruoyi.common.utils.SM4Utils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
import org.springframework.util.PathMatcher;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import javax.servlet.*;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 请求包装过滤器 - 处理请求解密和包装
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class RequestWrapperFilter implements Filter {
|
||||
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final PathMatcher pathMatcher = new AntPathMatcher();
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
|
||||
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
||||
HttpServletResponse httpResponse = (HttpServletResponse) response;
|
||||
|
||||
// 检查是否开启加解密功能
|
||||
if (!EncryptConstants.ENCRYPT_ENABLED) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是SSE流式响应 - 不处理
|
||||
if (isSseResponse(httpRequest, httpResponse)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查URL是否需要解密
|
||||
if (needProcess(httpRequest)) {
|
||||
// 处理请求解密
|
||||
String method = httpRequest.getMethod();
|
||||
if("POST".equalsIgnoreCase(method)) {
|
||||
HttpServletRequest processedRequest = processBodyRequest(httpRequest);
|
||||
chain.doFilter(processedRequest, response);
|
||||
}else{
|
||||
EncryptHttpServletResponseWrapper responseWrapper = new EncryptHttpServletResponseWrapper(httpResponse);
|
||||
String forwardUrl = buildGetRequestURI(httpRequest);
|
||||
if (StringUtils.isNotBlank(forwardUrl)) {
|
||||
log.info("GET请求解密后转发URL:{}", forwardUrl);
|
||||
// 服务器内部转发
|
||||
request.setAttribute("ENCRYPT_PROCESSED", Boolean.TRUE);
|
||||
RequestDispatcher dispatcher = request.getRequestDispatcher(forwardUrl);
|
||||
dispatcher.forward(request, responseWrapper);
|
||||
|
||||
// 2. 监听/处理转发后的响应(核心逻辑:从包装器中获取响应数据)
|
||||
byte[] content = responseWrapper.getContent();
|
||||
String originalResponse = new String(content, StandardCharsets.UTF_8);
|
||||
if (StringUtils.isNotBlank(originalResponse) && !originalResponse.trim().isEmpty()) {
|
||||
String encryptedResponse = SM4Utils.encryptEcb(SM4Constants.SM4_KET, originalResponse);
|
||||
|
||||
// 构建加密响应
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("encrypted", true);
|
||||
result.put("encryptedData", encryptedResponse);
|
||||
result.put("timestamp", System.currentTimeMillis());
|
||||
|
||||
String finalResponse = objectMapper.writeValueAsString(result);
|
||||
|
||||
// 设置响应
|
||||
byte[] encryptedBytes = finalResponse.getBytes(StandardCharsets.UTF_8);
|
||||
httpResponse.setContentLength(encryptedBytes.length);
|
||||
httpResponse.setContentType("application/json;charset=UTF-8");
|
||||
httpResponse.getOutputStream().write(encryptedBytes);
|
||||
} else {
|
||||
// 空响应直接返回空
|
||||
httpResponse.getOutputStream().write(new byte[0]);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 不需要解密,直接放行
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断请求是否需要处理
|
||||
*/
|
||||
private boolean needProcess(HttpServletRequest request) {
|
||||
if (Boolean.TRUE.equals(request.getAttribute("ENCRYPT_PROCESSED"))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String requestURI = request.getRequestURI();
|
||||
|
||||
// 检查URL是否匹配需要加解密的模式
|
||||
for (String pattern : EncryptConstants.URL_PATTERNS) {
|
||||
if (pathMatcher.match(pattern, requestURI)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是SSE流式响应
|
||||
*/
|
||||
private boolean isSseResponse(HttpServletRequest request, HttpServletResponse response) {
|
||||
String path = request.getRequestURI();
|
||||
String method = request.getMethod();
|
||||
|
||||
// 检查是否是聊天接口
|
||||
if ("/app/chat/chat".equals(path) && "POST".equalsIgnoreCase(method)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查Accept头是否包含text/event-stream
|
||||
String acceptHeader = request.getHeader("Accept");
|
||||
if (acceptHeader != null && acceptHeader.contains("text/event-stream")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查响应头是否已经设置为text/event-stream
|
||||
String contentType = response.getContentType();
|
||||
if (contentType != null && contentType.contains("text/event-stream")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private HttpServletRequest processBodyRequest(HttpServletRequest request) throws IOException {
|
||||
String body = getRequestBody(request);
|
||||
log.info("过滤器 - 原始请求体: " + body);
|
||||
|
||||
if (StringUtils.isNotBlank(body)) {
|
||||
try {
|
||||
// 解析请求体,提取encryptedData字段
|
||||
JsonNode jsonNode = objectMapper.readTree(body);
|
||||
JsonNode encryptedDataNode = jsonNode.get("encryptedData");
|
||||
|
||||
if (encryptedDataNode != null && encryptedDataNode.isTextual()) {
|
||||
String encryptedData = encryptedDataNode.asText();
|
||||
// 解密 encryptedData
|
||||
String decryptedData = SM4Utils.decryptEcb(SM4Constants.SM4_KET, encryptedData);
|
||||
|
||||
// 使用解密后的数据创建包装请求
|
||||
return new EncryptHttpServletRequestWrapper(request, decryptedData);
|
||||
} else {
|
||||
log.info("请求体中未找到encryptedData字段,直接返回原始请求");
|
||||
return request;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("POST请求体解密失败: " + e.getMessage());
|
||||
// 解密失败时返回原始请求,让业务层处理
|
||||
return request;
|
||||
}
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
private String getRequestBody(HttpServletRequest request) throws IOException {
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
BufferedReader bufferedReader = null;
|
||||
try {
|
||||
InputStream inputStream = request.getInputStream();
|
||||
if (inputStream != null) {
|
||||
bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
|
||||
char[] charBuffer = new char[128];
|
||||
int bytesRead;
|
||||
while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
|
||||
stringBuilder.append(charBuffer, 0, bytesRead);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (bufferedReader != null) {
|
||||
bufferedReader.close();
|
||||
}
|
||||
}
|
||||
return stringBuilder.toString();
|
||||
}
|
||||
|
||||
private String buildGetRequestURI(HttpServletRequest request) {
|
||||
String baseUrl = request.getRequestURI(); // 完整基础URL
|
||||
String encryptedData = request.getParameter("encryptedData");
|
||||
|
||||
if (StringUtils.isBlank(encryptedData)) {
|
||||
log.info("GET请求未携带encryptedData参数,返回原始URL");
|
||||
return null; // 无加密参数,不处理
|
||||
}
|
||||
|
||||
try {
|
||||
// 解密参数(增加异常捕获,避免解密失败导致URL拼接异常)
|
||||
String decryptedData = SM4Utils.decryptEcb(SM4Constants.SM4_KET, encryptedData);
|
||||
if (StringUtils.isBlank(decryptedData)) {
|
||||
log.error("GET请求encryptedData解密后为空");
|
||||
return null;
|
||||
}
|
||||
|
||||
JSONObject object = JSONObject.parseObject(decryptedData);
|
||||
if (object.isEmpty()) {
|
||||
log.info("解密后的参数为空,返回原始URL");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 拼接参数(用StringBuilder,避免字符串频繁拼接)
|
||||
StringBuilder params = new StringBuilder();
|
||||
for (Map.Entry<String, Object> entry : object.entrySet()) { // 用entrySet更高效
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
|
||||
// 跳过空值和空key
|
||||
if (StringUtils.isBlank(key) || Objects.isNull(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理参数值:数组/嵌套JSON转为JSON字符串,普通类型直接转字符串
|
||||
String paramValue = String.valueOf(value);
|
||||
|
||||
// URL编码(处理中文、特殊字符,如空格、&、=等)
|
||||
String encodedValue = URLEncoder.encode(paramValue, "UTF-8");
|
||||
if (params.length() > 0) {
|
||||
params.append("&");
|
||||
}
|
||||
params.append(key).append("=").append(encodedValue);
|
||||
}
|
||||
|
||||
// 拼接完整URL(仅当有参数时才加?)
|
||||
if (params.length() > 0) {
|
||||
return baseUrl + "?" + params.toString();
|
||||
} else {
|
||||
log.info("解密后的参数均为空,返回原始URL");
|
||||
return null;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("GET请求参数解密/拼接失败", e);
|
||||
return null; // 异常时返回null,走原始请求
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.ruoyi.common.utils;
|
||||
|
||||
import javax.servlet.ReadListener;
|
||||
import javax.servlet.ServletInputStream;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletRequestWrapper;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 请求包装器 - 用于读取和修改请求体
|
||||
*/
|
||||
public class EncryptHttpServletRequestWrapper extends HttpServletRequestWrapper {
|
||||
private final String body;
|
||||
|
||||
public EncryptHttpServletRequestWrapper(HttpServletRequest request, String body) {
|
||||
super(request);
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServletInputStream getInputStream() throws IOException {
|
||||
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
|
||||
return new ServletInputStream() {
|
||||
@Override
|
||||
public boolean isFinished() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setReadListener(ReadListener readListener) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
return byteArrayInputStream.read();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedReader getReader() throws IOException {
|
||||
return new BufferedReader(new InputStreamReader(this.getInputStream()));
|
||||
}
|
||||
|
||||
public String getBody() {
|
||||
return this.body;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.ruoyi.common.utils;
|
||||
|
||||
import javax.servlet.ServletOutputStream;
|
||||
import javax.servlet.WriteListener;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpServletResponseWrapper;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 响应包装器 - 用于修改响应内容
|
||||
*/
|
||||
public class EncryptHttpServletResponseWrapper extends HttpServletResponseWrapper {
|
||||
private ByteArrayOutputStream buffer;
|
||||
private ServletOutputStream out;
|
||||
private PrintWriter writer;
|
||||
|
||||
public EncryptHttpServletResponseWrapper(HttpServletResponse response) {
|
||||
super(response);
|
||||
buffer = new ByteArrayOutputStream();
|
||||
out = new WrappedOutputStream(buffer);
|
||||
writer = new PrintWriter(new OutputStreamWriter(buffer, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServletOutputStream getOutputStream() throws IOException {
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PrintWriter getWriter() throws IOException {
|
||||
return writer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flushBuffer() throws IOException {
|
||||
if (out != null) {
|
||||
out.flush();
|
||||
}
|
||||
if (writer != null) {
|
||||
writer.flush();
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] getContent() throws IOException {
|
||||
flushBuffer();
|
||||
return buffer.toByteArray();
|
||||
}
|
||||
|
||||
private static class WrappedOutputStream extends ServletOutputStream {
|
||||
private ByteArrayOutputStream buffer;
|
||||
|
||||
public WrappedOutputStream(ByteArrayOutputStream buffer) {
|
||||
this.buffer = buffer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int b) throws IOException {
|
||||
buffer.write(b);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setWriteListener(WriteListener writeListener) {
|
||||
}
|
||||
}
|
||||
}
|
||||
211
ruoyi-common/src/main/java/com/ruoyi/common/utils/SM4Utils.java
Normal file
211
ruoyi-common/src/main/java/com/ruoyi/common/utils/SM4Utils.java
Normal file
File diff suppressed because one or more lines are too long
@@ -3,6 +3,9 @@ package com.ruoyi.framework.config;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import javax.servlet.DispatcherType;
|
||||
|
||||
import com.ruoyi.common.filter.EncryptResponseFilter;
|
||||
import com.ruoyi.common.filter.RequestWrapperFilter;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
@@ -55,4 +58,48 @@ public class FilterConfig
|
||||
return registration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求包装过滤器 - 处理请求体解密
|
||||
*/
|
||||
@SuppressWarnings({ "rawtypes", "unchecked" })
|
||||
@Bean
|
||||
public FilterRegistrationBean requestWrapperFilterRegistration()
|
||||
{
|
||||
FilterRegistrationBean registration = new FilterRegistrationBean();
|
||||
registration.setDispatcherTypes(DispatcherType.REQUEST);
|
||||
registration.setFilter(new RequestWrapperFilter());
|
||||
registration.addUrlPatterns("/*");
|
||||
registration.setName("requestWrapperFilter");
|
||||
registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE + 1);
|
||||
|
||||
// 设置排除的URL模式
|
||||
Map<String, String> initParameters = new HashMap<>();
|
||||
initParameters.put("excludes", "/app/chat/chat");
|
||||
registration.setInitParameters(initParameters);
|
||||
|
||||
return registration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应加密过滤器 - 处理响应内容加密
|
||||
*/
|
||||
@SuppressWarnings({ "rawtypes", "unchecked" })
|
||||
@Bean
|
||||
public FilterRegistrationBean encryptResponseFilterRegistration()
|
||||
{
|
||||
FilterRegistrationBean registration = new FilterRegistrationBean();
|
||||
registration.setDispatcherTypes(DispatcherType.REQUEST);
|
||||
registration.setFilter(new EncryptResponseFilter());
|
||||
registration.addUrlPatterns("/*");
|
||||
registration.setName("encryptResponseFilter");
|
||||
registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE + 2);
|
||||
|
||||
// 设置排除的URL模式
|
||||
Map<String, String> initParameters = new HashMap<>();
|
||||
initParameters.put("excludes", "/app/chat/chat");
|
||||
registration.setInitParameters(initParameters);
|
||||
|
||||
return registration;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user