diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index e52e560..afe3bee 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -183,4 +183,20 @@ oauth: tyQueryUnitInfo: http://10.98.80.146/qxgl_backend/security/get_organization_by_organizationid connect-timeout: 10 read-timeout: 30 - write-timeout: 30 \ No newline at end of file + 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 \ No newline at end of file diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/config/ChatClient.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/config/ChatClient.java new file mode 100644 index 0000000..36c3655 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/config/ChatClient.java @@ -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 TEXT_FILE_EXTENSIONS= Arrays.asList(".txt", ".md", ".html", ".doc", ".docx", ".pdf", ".ppt", ".pptx", ".csv", ".xls", ".xlsx"); + public static final List 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); + } +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/config/ChatConfig.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/config/ChatConfig.java new file mode 100644 index 0000000..f2beb6d --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/config/ChatConfig.java @@ -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; + } +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/ChatController.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/ChatController.java new file mode 100644 index 0000000..693f3cd --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/ChatController.java @@ -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 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 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()); + } + } + +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/ai/AiChatDetail.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/ai/AiChatDetail.java new file mode 100644 index 0000000..e3d2c5f --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/ai/AiChatDetail.java @@ -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; +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/ai/AiChatHistory.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/ai/AiChatHistory.java new file mode 100644 index 0000000..e93acb7 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/ai/AiChatHistory.java @@ -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 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; + +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/chat/ChatRequest.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/chat/ChatRequest.java new file mode 100644 index 0000000..2ca62f7 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/chat/ChatRequest.java @@ -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 fileUrl; + + private JSONArray messages; +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/AiChatDetailMapper.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/AiChatDetailMapper.java new file mode 100644 index 0000000..d5181b4 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/AiChatDetailMapper.java @@ -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 { + + List getList(AiChatDetail aiChatDetail); +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/AiChatHistoryMapper.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/AiChatHistoryMapper.java new file mode 100644 index 0000000..851f95c --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/AiChatHistoryMapper.java @@ -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 { + + List getList(AiChatHistory aiChatHistory); +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/AiChatHistoryService.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/AiChatHistoryService.java new file mode 100644 index 0000000..fad7bf3 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/AiChatHistoryService.java @@ -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 { + + JSONObject getList(AiChatHistory aiChatHistory); + + void saveChatHistory(AiChatHistory aiChatHistory); + + JSONObject getDetailList(String chatId); + + JSONArray getChatHistoryData(String chatId); + +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/AiChatHistoryServiceImpl.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/AiChatHistoryServiceImpl.java new file mode 100644 index 0000000..81bd800 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/AiChatHistoryServiceImpl.java @@ -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 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 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 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 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 list = aiChatDetailMapper.getList(detail); + if(list!=null&&!list.isEmpty()){ + JSONObject data = new JSONObject(); + JSONArray value = new JSONArray(); + JSONObject valueObject = new JSONObject(); + String dataId = ""; + List contentList = new ArrayList<>(); + List 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 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; + } +} diff --git a/ruoyi-bussiness/src/main/resources/mapper/app/AiChatDetailMapper.xml b/ruoyi-bussiness/src/main/resources/mapper/app/AiChatDetailMapper.xml new file mode 100644 index 0000000..a61d327 --- /dev/null +++ b/ruoyi-bussiness/src/main/resources/mapper/app/AiChatDetailMapper.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/ruoyi-bussiness/src/main/resources/mapper/app/AiChatHistoryMapper.xml b/ruoyi-bussiness/src/main/resources/mapper/app/AiChatHistoryMapper.xml new file mode 100644 index 0000000..f432fbc --- /dev/null +++ b/ruoyi-bussiness/src/main/resources/mapper/app/AiChatHistoryMapper.xml @@ -0,0 +1,26 @@ + + + + + + + \ No newline at end of file diff --git a/ruoyi-common/pom.xml b/ruoyi-common/pom.xml index b38aa2f..c728f84 100644 --- a/ruoyi-common/pom.xml +++ b/ruoyi-common/pom.xml @@ -120,6 +120,22 @@ UserAgentUtils + + + + + org.bouncycastle + bcprov-jdk15on + 1.59 + compile + + javax.servlet diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/constant/EncryptConstants.java b/ruoyi-common/src/main/java/com/ruoyi/common/constant/EncryptConstants.java new file mode 100644 index 0000000..956349d --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/constant/EncryptConstants.java @@ -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 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"*/ + ); +} \ No newline at end of file diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/constant/SM4Constants.java b/ruoyi-common/src/main/java/com/ruoyi/common/constant/SM4Constants.java new file mode 100644 index 0000000..01589c8 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/constant/SM4Constants.java @@ -0,0 +1,6 @@ +package com.ruoyi.common.constant; + +public class SM4Constants { + + public static final String SM4_KET = "86C63180C1306ABC4D8F989E0A0BC9F3"; +} diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/enums/BusinessType.java b/ruoyi-common/src/main/java/com/ruoyi/common/enums/BusinessType.java index 2e17c4a..6a2044a 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/enums/BusinessType.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/enums/BusinessType.java @@ -56,4 +56,8 @@ public enum BusinessType * 清空数据 */ CLEAN, + /** + * 查询 + */ + QUERY } diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/filter/EncryptResponseFilter.java b/ruoyi-common/src/main/java/com/ruoyi/common/filter/EncryptResponseFilter.java new file mode 100644 index 0000000..0f41ca5 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/filter/EncryptResponseFilter.java @@ -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 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; + } +} \ No newline at end of file diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/filter/RequestWrapperFilter.java b/ruoyi-common/src/main/java/com/ruoyi/common/filter/RequestWrapperFilter.java new file mode 100644 index 0000000..a256979 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/filter/RequestWrapperFilter.java @@ -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 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 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,走原始请求 + } + } +} \ No newline at end of file diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/EncryptHttpServletRequestWrapper.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/EncryptHttpServletRequestWrapper.java new file mode 100644 index 0000000..6430a95 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/EncryptHttpServletRequestWrapper.java @@ -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; + } +} + diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/EncryptHttpServletResponseWrapper.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/EncryptHttpServletResponseWrapper.java new file mode 100644 index 0000000..2c32b65 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/EncryptHttpServletResponseWrapper.java @@ -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) { + } + } +} \ No newline at end of file diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/SM4Utils.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/SM4Utils.java new file mode 100644 index 0000000..061f7c2 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/SM4Utils.java @@ -0,0 +1,211 @@ +package com.ruoyi.common.utils; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.pqc.math.linearalgebra.ByteUtils; +import org.bouncycastle.util.encoders.Hex; +import org.springframework.util.Base64Utils; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.spec.SecretKeySpec; +import java.security.Key; +import java.security.SecureRandom; +import java.security.Security; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class SM4Utils { + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + private static final String ENCODING = "UTF-8"; + public static final String ALGORITHM_NAME = "SM4"; + /** + * 加密算法/分组加密模式/分组填充方式 + * PKCS5Padding-以8个字节为一组进行分组加密 + * 定义分组加密模式使用:PKCS5Padding + */ + public static final String ALGORITHM_NAME_ECB_PADDING = "SM4/ECB/PKCS5Padding"; + /** 128-32位16进制;256-64位16进制 */ + public static final int DEFAULT_KEY_SIZE = 128; + + private static Pattern p = Pattern.compile("\\s*|\t|\r|\n"); + + /** + * 自动生成密钥 + * + * @return + * @explain + */ + public static String generateKey() { + try { + return new String(Hex.encode(generateKey(DEFAULT_KEY_SIZE))); + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + /** + * @param keySize + * @return + * @throws Exception + * @explain + */ + public static byte[] generateKey(int keySize) throws Exception { + KeyGenerator kg = KeyGenerator.getInstance(ALGORITHM_NAME, BouncyCastleProvider.PROVIDER_NAME); + kg.init(keySize, new SecureRandom()); + return kg.generateKey().getEncoded(); + } + + /** + * 生成ECB暗号 + * + * @param algorithmName 算法名称 + * @param mode 模式 + * @param key + * @return + * @throws Exception + * @explain ECB模式(电子密码本模式:Electronic codebook) + */ + private static Cipher generateEcbCipher(String algorithmName, int mode, byte[] key) throws Exception { + Cipher cipher = Cipher.getInstance(algorithmName, BouncyCastleProvider.PROVIDER_NAME); + Key sm4Key = new SecretKeySpec(key, ALGORITHM_NAME); + cipher.init(mode, sm4Key); + return cipher; + } + + /** + * sm4加密 + * + * @param hexKey 16进制密钥(忽略大小写) + * @param paramStr 待加密字符串 + * @explain 加密模式:ECB + * 密文长度不固定,会随着被加密字符串长度的变化而变化 + */ + public static String encryptEcb(String hexKey, String paramStr) { + return encryptEcb(hexKey, paramStr, true); + } + + /** + * sm4加密 + * + * @param hexKey 16进制密钥(忽略大小写) + * @param paramStr 待加密字符串 + * @param isHex 是否返回16进制的加密字符串 + * @return 返回16进制的加密字符串或base64String + * @explain 加密模式:ECB + * 密文长度不固定,会随着被加密字符串长度的变化而变化 + */ + public static String encryptEcb(String hexKey, String paramStr, boolean isHex) { + try { + String cipherText = ""; + // 16进制字符串-->byte[] + byte[] keyData = ByteUtils.fromHexString(hexKey); + // String-->byte[] + byte[] srcData = paramStr.getBytes(ENCODING); + // 加密后的数组 + byte[] cipherArray = encrypt_Ecb_Padding(keyData, srcData); + // byte[]-->hexString + if (isHex) { + cipherText = ByteUtils.toHexString(cipherArray); + } else { + // byte[]--> base64String + cipherText = Base64Utils.encodeToString(cipherArray); + if (cipherText != null && cipherText.trim().length() > 0) { + Matcher m = p.matcher(cipherText); + cipherText = m.replaceAll(""); + } + } + return cipherText; + } catch (Exception e) { + return paramStr; + } + } + + /** + * 加密模式之Ecb + * + * @param key + * @param data + * @return + * @throws Exception + * @explain + */ + public static byte[] encrypt_Ecb_Padding(byte[] key, byte[] data) throws Exception { + Cipher cipher = generateEcbCipher(ALGORITHM_NAME_ECB_PADDING, Cipher.ENCRYPT_MODE, key); + return cipher.doFinal(data); + } + + /** + * sm4解密 + * + * @param hexKey 16进制密钥 + * @param cipherText 16进制的加密字符串(忽略大小写) + * @return 解密后的字符串 + * @throws Exception + * @explain 解密模式:采用ECB + */ + public static String decryptEcb(String hexKey, String cipherText) { + return decryptEcb(hexKey, cipherText, true); + } + + /** + * sm4解密 + * + * @param hexKey 16进制密钥 + * @param cipherText 16进制的加密字符串(忽略大小写) + * @return 解密后的字符串 + * @throws Exception + * @explain 解密模式:采用ECB + */ + public static String decryptEcb(String hexKey, String cipherText, boolean isHex) { + // 用于接收解密后的字符串 + String decryptStr = ""; + // hexString-->byte[] + byte[] keyData = ByteUtils.fromHexString(hexKey); + byte[] cipherData; + if (isHex) { + // hexString --> byte[] + cipherData = ByteUtils.fromHexString(cipherText); + } else { + // base64String --> byte[] + cipherData = Base64Utils.decodeFromString(cipherText); + } + try { + // 解密 + byte[] srcData = decrypt_Ecb_Padding(keyData, cipherData); + // byte[]-->String + decryptStr = new String(srcData, ENCODING); + } catch (Exception e) { + e.printStackTrace(); + } + return decryptStr; + } + + /** + * 解密 + * + * @param key + * @param cipherText + * @return + * @throws Exception + * @explain + */ + public static byte[] decrypt_Ecb_Padding(byte[] key, byte[] cipherText) throws Exception { + Cipher cipher = generateEcbCipher(ALGORITHM_NAME_ECB_PADDING, Cipher.DECRYPT_MODE, key); + return cipher.doFinal(cipherText); + } + + public static void main(String[] args) { + //加密 +// System.out.println(encryptEcb("06e6af183547026dd66eae8c0827acc7","a3b129c34a0b06800c7506075ebee2af")); + //解密 + System.out.println(decryptEcb("8c10e08760bb0f8465c8e8390c8c4670","fd24f99e4007788b0ea7a003ab96f7bcd0b677a5925ba1b111cadda9c108665d8d5788bac31694a2d5f83d629c74a4addde17d2fb32a5e4fa5068d857fec04c675de24b58d84de6331eebd6c191f5a4b0bddc30b0fe2462b8911c54d6149a1991509bdbb7de00257b753cd165e90e1b5cfaa1cf994e01d21df3725599c058ccf29ea5c6cc06ce2b91828c890bb164d61bc706a5093a814173605c0b36536d77a425f178f84e95d4058ca28811c7ddecce3515327555ff536579832e3ed0a26adabbf2ac22e443c12579d8d9bf066faf8db8d26ec316378dd12e97f7bdea0c51cdfb5c8f4ad199c4db29623bca807d9a26ec4fd1e59b562b588b8923253608eaa7c5d1ba4e47cf8d6eece514836cf8d18991157befab3df18bd8d38a5e1e208118977454f41f5327e02aad1d18203a1e0e71b7fc75510f532879ad037ff299a756ac1d665773ffd12e362825c45c4fd395e51a8f2a0cd94a2a671d09ef49f3e4845b58fd9959172460bb446c7911677a74e60259c6b1df5f9ddabc7da365b7ed285150de1f4ca551f43535deff943e9c63ec979bda9a7bb402f8f654cd5bf3b327a7671331e112d02fa6ad552391a032a53c512d469d13a07ae83e5c458caecad32587ebb06171b1db299e80b3bd2416c03579e679f5f0254900f97d216b296faa5fdd5d5b00d6da52bcd327cb66656c5dd659c6bd343070399fa8653e72734ce6cedba70c68c189f6a58258816c2c02ba5b368c5c2ed00c25ebd7a8e71534a4bfad9c06586c0111cfa9445345dce93330b53f6af32931ae3a8b6b2821dc8623846fda300eeb69484340c958e588521a903ff1ec2beb14d9d6c78506ff79957722a191406ea6345252e7dec630cb46869d4cb3160db78fbac93927598649de7ecf386138864cef931d56d1013936a922c46c3ed5d924a37e79142d5514fa46e48e0a38e1575c55d2d1053fe7cea649bc005ee49584d2f42a7525285ada0dbcbcfeab4c09efe2cf981f56ec18816d5cd5f90098bbba2257d8353922c4b2f009762f144031ebd0e5c6d7ce9437a4bde7f6e4972494242f69896947baf49ee71a50f2cfec157d9d5c5106e3a659153401d2df2d2f09e07fc62505e9995e6a3f206a0fd0fac830e76598bcae22d409dcf1ddcd134e0c3bb2db4d1bcb5fd4b65d55a9f268859aa16207ed998cc0ddf6cb840b38454aec10e5770e7150f0ce97db9bce166eefe096f26110ee71aa27cdbca6e42c3cd1a05179e5aa31af8f9ec6e54de3f4e51e739a66376025615c55b1eafaecade0333abe0397ffaa75d72a82c6d75df305c3d1b3d30c29058585dc4f1675c5afb1f9982c9c6f4caa6cc2ce90e57df8d0c26cb5c8e8e839f6361208fc8038882ed2914857585e76a72cde36ad6d144395eac688fb5fa8d46338df9639fb8bf86e20b39054a94981306408326161382d14e5bae5d641c44883cf13f22d9ccf1f0c06577c383f8dd230208a2920f59fe926f694a3790c62d647f4b4a0f609962a5caa015661549f2a6b4a9ed1d3ad0f5dd3a7877ce78f1d85ae4d97ca2e153e13d443cc4c5a06b6111ff989b25dea29eb85093623507f342c89d212aa72c4e5839fdfde5c5d905299c8ade53b816e382cb73aceec12af81154d64ca0cbcc3c98c317395dd726aafa975e69e700106a1b42b20f7ea77fb881980d19f6b2bee678e551736d1667726420f89fa5a3e3d28ee29414e3b25b83011b127928dc4ba49e003da2e2e5b4f8c01d002d229f48d309bda4b53ec8c0ae155df5f91c11ad3a3a2c6a0217f9a47feef49411fcbd0228e5593d60185701e0c377e5c47958d52b0f706b9be26998bffae5199cda51af5ad7fa79bf46dcd8fbb1f135827f791b547d870014ca69f5bfb5b0a73180d75c6d9f13603e4506a174a8607fe85477760c904099d7d4696716aa9bb1c8744a18fb1ae37210349a811423b1165afc28b500d1095e16a1594364c012f5f6cc3bd9f64a64187b01bcda4875ae7646540548a465c2b56caf38d98ce6a5d0f75b3c074fb2248e01cae9eb3e78dbc01302054c738d0be6a7ed37761b337c0343b453e06c9700dee8fc00a705e4a747e1a5d46588c8bace9c68eb3832f0600e46f54e85a4a3a44ea0c29c69b5c5d6b6491f0a3f0bdbd123bafe3f3dd987fcc52e30c5c6cf1e9f02f998ea2791dc72cc75dac6033198c38cec44671465120ec946145b7171a63234b531e54253f92ed2c115cfe700d35e103741744d5c29f5e466109e7aba25fb9bcc38c472ae17108e3027b68d97b8d1d90c880d92a919cbce9881d6905a33f6ce5421c570a57f056ba70966e6472f49cd3d4f48c3261e0b7ae75cb34394d692ff2b80e0d596123811bbabccf90991b5b2dc17623a0f547478fef1a1cc2df258785141e7153030d8afb58c1e214632124436cbab0c21996b5dc27704635a4b27531cfddeeadcf90fd57c681bc2b0527ffd0106692d57f36af689ea77b06bc4030fad5cdf7e60e1f03d168bd3ff798f55a629f65b8fe731fc872ad44f58176d46442e5e3afcdb69302ad58802b5ed684d3e6365a77d7e931fc691b6e7d6c63897ba5c70e6e94eb7db177a19e6d97905769fd2a40ce8ef94dc14eeced2b8dc90dd423b8255f5e926662387a52c628af72e760b94a55ea5eccc41865c503e738047d6351a4ab17b2d5139a29c830f37ba364830ecd5c47d987ae1be041b7ad0d29fa769e78266b51f49fe738d132ec7bd9c005be3b8caae434d11078cb9483ede152664954dedf3ca5aa508fdc4ab00120bfac8191851cf335a6197fb33b0dd17de0abfb2a045cf85f4a0dfcfb10846b1c2e10cb776727e27c5987a86fd8396b807c7a50b0a7a5e83cb64187da3804e24c180b16c4da848ca211a88e869b4caa5f8a486b4c41ff119497f0ac1942274c2d9817c91bb03498bc90306716a254b4e13c8327270511d71b5dfa9a44fda1f979e9d8dc8d3cb0ac5df4f5fe4940936d8e665c5667d397b9c73e13fdf79e99d8484cb940dc2451ca0a3b5cff3eebe53f7ea3d6c97a1eb1959fde7353a9d5e27c7ec4f38dcc42e8319cf150d300a0209f5de6a9d2d43450e568d236715455e2632face4c24accb6c3a33d4a6ae4f014a78866195306cc55e817836393641f6f49c05f50e3862222b0ca344ab1059e8be26937066be9e5ed12445cab67dc2de15fa26c65eaa0b0933c22c2081accc692e4a0ce463b3609038d0dea1360181f3382f00b30e61e908b8bbd5acb7b65660a97a1c4d4ba7ccce2da3095a5d8d181abc916fd831f18478db3f1590681afb55f62273d670130fa2a195d7e2a4365de887a265513df5e49e31b51ddb54807f821fdedd7eea0779dfdfcf3e64299046d6e62cc3b34d4a826f813f627bbed52964f449fe4a714ed9be8b60f54073654d749642c069780f06a34d56f5f12c29516901382568a6d3493947b046f05bcfe03ef95666e8e6d760d88f57ccf50349af4fb0e7efe2b08c94937313750dfb84d32a08b20599c3ee12a8cd6b1496cb1a069e98797a8f789743172732e35090009cffd1686809c199ef527a89d8ebbd05dfef79c6a9a958acd89aa3ed9aa648627fe62cb760c9f10e6986b0069973bf7afa4d138b56464e7f69824d852accec36dd384083215aedd55f37fc79940a0a8832397ac5e3336a436324789247c51c3e948bdcf6fb3396fe75aa8dae4f29c58cf835dc6ff2b3c4d6c236f3ce8064c8bc7a8536118a36414c842fc1a8ab6e71242ecb655df0092dee733b137feaff4b6bfc47558cbd04b3684f7f8dd31a72fcfb104f3c9568edb9da4f0826512b65b87357f45e6b7d9813b3cb03b1c3f2e1363746925e27c26e8ad0c921803c1cdf7210665971afbd8d81eda9216a25ab8ff5bb224e8f6c759e46f7254e5716db943b782a9d16451e4de2e86d3b4444b8c060aedb32d630865badb0b96a1501aac1a6f67bd74cd0b0999b0ab8173df3ad1cf6845d13e68f09853f1b57b51381f85a792afa61e2d21bac341f78cfbfa52ccf586b0572d6dff5a91e57b81186fdc52c522ff72811fe1c5d52bdee4ce6ea290f0eff38820fa8eba22117ef88797aeeeaabc66ac1ae1ec674e75c7162e10304783f2ad497361f39078d92ab21ee7d26e24fb10466665c8cc4554c11984e584b8fccaa1a19fa60db2bb6fc96d5e518a8c1ab9a8223fa4a790e15443d82d88b78deb561c423f995bd46c0cf356993227f6e90cd4311fe8624ada05ecf9a1f9d0ba9c1935c84a8763f0069e6956c75a20f2a70a84b7e25133a2e35ffa81ea74978e03881c0fceff4f2fb8a757ee4e3ff752007decda9a91b2522b0a54f8369ac878fba41ecc93396008c0d45ac019dcde8b3fa001583c740a507af08818465bedc7b103a4a8884d8fbf653f4e7a0c6b28fbe3507c7fe680429cb15def6e817c2e184fc32ff5f97a931a3f873ccce5f55b727eb270e18f343639102b5dabad5b727b7c76d80e6f61fa9b97b52c61fc703f8c8abbdf5355aaf72f22858a102ffee2fd147dd00d53141b734ee03ed9b587572f89dc2e251d207781b78e92a370cc456b3f297ea63155d530962c2159d23ea02735e5fdacecdae91888196f30c1bef6ab4643344f632e8778d6b2d102d69207f65c20e8976ba230a4c8e2c18e60d900352748570e9198d7b836cfb7f81b4d83bc52a9f6d146cc9f50c4f0d920c5b85ef51c6444fcb8b9c3e42862c345ea9ddd6b33370d36ed2779b6ca46072806e24799758b01eb7ac6570589f79b382825d4fb6ef5f02a58c6b2431c466723ee41a78f12fbfdbb2cf51514627284683f1808541cfee130aa4df80c89bd6cb92f7525a786bcd527decf10025e46291bac454a2ecab6423d9ec9327a0965abccafe69e02c83cf7faddaf6c2aa80e23261ca2174b28e36d4c46d71e68f03b4a52d8e010f36a1b12f51254c0c0b6d3ae749dc4dc292f4941353a664fafb8b2ef40d14cc3f30ba3e33b6478330e5070d3b5193c4e8dca4db9f9b2b126866ff75f11e84b98b7a7abcafa65366957e968e520317bbd42cef26008074a1ca4773e38a0e24aa4e89d74778afe2711ce979f20e8a1673b42f987dae34979fa4c832af2134f0cc625d03b77392975c344c7cb028edf407b3d987aabcd8386c29420564dff494bb4ddbbb221de3b04d01be347ac64e237a946f1bc27c264cd8d0d5f090f4bf01baf7fd652ea0eb4ab3707fe50dadb68fc78e3f4b2ee86fa39349a7bfeb6c68ed5082d9848efd93b36b6147ba5c6135d58925491f5b4119e94337ba8c5f78482d777fcc8b41059a30b956f2279c8e7e573fd220ce8f890635282093c0aea98fa8db8c29c229672d8ea3706d6757870ab6db03e881e48427c929b7478c345121289c05e43ec7bf6e1eda28")); + +// System.out.println(generateKey()); + } + +} diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/FilterConfig.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/FilterConfig.java index bb14c04..144bdaf 100644 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/FilterConfig.java +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/FilterConfig.java @@ -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 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 initParameters = new HashMap<>(); + initParameters.put("excludes", "/app/chat/chat"); + registration.setInitParameters(initParameters); + + return registration; + } + }