refactor: 重构聊天模块架构

- 删除废弃的ChatMessageDTO、ChatContext、AbstractChatMessageService等类
- 迁移ChatServiceFactory和IChatMessageService到ruoyi-chat模块
- 重构ChatHandler体系,移除DefaultChatHandler和ChatContextBuilder
- 优化SSE消息处理,新增SseEventDto
- 简化各AI服务提供商实现类代码
- 优化工作流节点消息处理逻辑

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ageerle
2026-03-20 01:20:41 +08:00
parent f582f38570
commit c84d6247b0
39 changed files with 933 additions and 1394 deletions

View File

@@ -1,45 +0,0 @@
package org.ruoyi.common.chat.domain.dto;
import lombok.Data;
/**
* 聊天消息DTO - 用于上下文传递
*
* @author ageerle@163.com
* @date 2025/12/13
*/
@Data
public class ChatMessageDTO {
/**
* 消息角色: system/user/assistant
*/
private String role;
/**
* 消息内容
*/
private String content;
public static ChatMessageDTO system(String content) {
ChatMessageDTO msg = new ChatMessageDTO();
msg.role = "system";
msg.content = content;
return msg;
}
public static ChatMessageDTO user(String content) {
ChatMessageDTO msg = new ChatMessageDTO();
msg.role = "user";
msg.content = content;
return msg;
}
public static ChatMessageDTO assistant(String content) {
ChatMessageDTO msg = new ChatMessageDTO();
msg.role = "assistant";
msg.content = content;
return msg;
}
}

View File

@@ -1,11 +1,11 @@
package org.ruoyi.common.chat.domain.dto.request;
import dev.langchain4j.data.message.ChatMessage;
import com.alibaba.fastjson.annotation.JSONField;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import org.ruoyi.common.chat.domain.dto.ChatMessageDTO;
import java.util.List;
/**
* 对话请求对象
@@ -16,11 +16,15 @@ import java.util.List;
@Data
public class ChatRequest {
@NotEmpty(message = "对话消息不能为空")
private List<ChatMessageDTO> messages;
@NotEmpty(message = "传入的模型不能为空")
private String model;
/**
* 对话消息
*/
@NotEmpty(message = "对话消息不能为空")
private String content;
/**
* 工作流请求体
*/
@@ -31,59 +35,49 @@ public class ChatRequest {
*/
private ReSumeRunner reSumeRunner;
/**
* 是否为人机交互用户继续输入
*/
private Boolean isResume = false;
/**
* 是否启用工作流
*/
private Boolean enableWorkFlow;
private Boolean enableWorkFlow = false;
/**
* 会话id
*/
@JsonSerialize(using = ToStringSerializer.class)
@JSONField(serializeUsing = String.class)
private Long sessionId;
/**
* 应用ID
*/
private String appId;
/**
* 知识库id
*/
private String knowledgeId;
/**
* 对话id(每个聊天窗口都不一样)
* 应用ID
*/
private Long uuid;
private String appId;
/**
* 是否为人机交互用户继续输入
* 对话id(每个聊天窗口都不一样)
*/
private Boolean isResume;
@JsonSerialize(using = ToStringSerializer.class)
@JSONField(serializeUsing = String.class)
private Long uuid;
/**
* 是否启用深度思考
*/
private Boolean enableThinking;
/**
* 是否自动切换模型
*/
private Boolean autoSelectModel;
private Boolean enableThinking = false;
/**
* 是否支持联网
*/
private Boolean enableInternet;
/**
* 会话令牌为避免在非Web线程中获取Request入口处注入
*/
private String token;
/**
* 原生对话对象
*/
private List<ChatMessage> chatMessages;
}

View File

@@ -5,8 +5,6 @@ import cn.idev.excel.annotation.ExcelProperty;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.ruoyi.common.chat.entity.chat.ChatMessage;
import org.ruoyi.common.excel.annotation.ExcelDictFormat;
import org.ruoyi.common.excel.convert.ExcelDictConvert;
import java.io.Serial;
import java.io.Serializable;

View File

@@ -6,12 +6,14 @@ import com.baomidou.mybatisplus.annotation.TableId;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
public class BaseEntity implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)

View File

@@ -1,63 +0,0 @@
package org.ruoyi.common.chat.entity.chat;
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.ruoyi.common.chat.service.chat.IChatService;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
* 聊天对话上下文对象
*
* @author zengxb
* @date 2026-02-14
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Builder
public class ChatContext {
/**
* 模型管理视图对象
*/
@NotNull(message = "模型管理视图对象不能为空")
private ChatModelVo chatModelVo;
/**
* 对话请求对象
*/
@NotNull(message = "对话请求对象不能为空")
private ChatRequest chatRequest;
/**
* SSe连接对象
*/
@NotNull(message = "SSe连接对象不能为空")
private SseEmitter emitter;
/**
* 用户ID
*/
@NotNull(message = "用户ID不能为空")
private Long userId;
/**
* Token
*/
@NotNull(message = "Token不能为空")
private String tokenValue;
/**
* 响应处理器
*/
private StreamingChatResponseHandler handler;
/**
* 聊天服务实例
*/
private IChatService chatService;
}

View File

@@ -1,46 +0,0 @@
package org.ruoyi.common.chat.factory;
import org.ruoyi.common.chat.service.chat.IChatService;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 聊天服务工厂类
*
* @author ageerle@163.com
* @date 2025-12-13
*/
@Component
public class ChatServiceFactory implements ApplicationContextAware {
private final Map<String, IChatService> chatServiceMap = new ConcurrentHashMap<>();
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
// 初始化时收集所有IChatService的实现
Map<String, IChatService> serviceMap = applicationContext.getBeansOfType(IChatService.class);
for (IChatService service : serviceMap.values()) {
if (service != null ) {
chatServiceMap.put(service.getProviderName(), service);
}
}
}
/**
* 获取原始服务(不包装代理)
*/
public IChatService getOriginalService(String category) {
IChatService service = chatServiceMap.get(category);
if (service == null) {
throw new IllegalArgumentException("不支持的模型类别: " + category);
}
return service;
}
}

View File

@@ -1,7 +1,8 @@
package org.ruoyi.common.chat.service.chat;
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
import jakarta.validation.Valid;
import org.ruoyi.common.chat.entity.chat.ChatContext;
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
@@ -12,10 +13,15 @@ public interface IChatService {
/**
* 客户端发送对话消息到服务端
*/
SseEmitter chat(@Valid ChatContext chatContext);
SseEmitter chat(@Valid ChatRequest chatRequest);
/**
* 获取服务提供商名称
* 支持外部 handler 的对话接口(跨模块调用)
* 同时发送到 SSE 和外部 handler
*
* @param chatRequest 聊天请求
* @param externalHandler 外部响应处理器(可为 null
*/
String getProviderName();
void chat(@Valid ChatRequest chatRequest, StreamingChatResponseHandler externalHandler);
}

View File

@@ -1,58 +0,0 @@
package org.ruoyi.common.chat.service.chatMessage;
import org.ruoyi.common.chat.domain.bo.chat.ChatMessageBo;
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
/**
* 聊天信息抽象基类 - 保存聊天信息
*
* @author Zengxb
* @date 2026-02-24
*/
public abstract class AbstractChatMessageService {
/**
* 创建日志对象
*/
Logger log = LoggerFactory.getLogger(AbstractChatMessageService.class);
@Autowired
private IChatMessageService chatMessageService;
/**
* 保存聊天信息
*/
public void saveChatMessage(ChatRequest chatRequest, Long userId, String content, String role, ChatModelVo chatModelVo){
try {
// 验证必要的上下文信息
if (chatRequest == null || userId == null) {
log.warn("缺少必要的聊天上下文信息,无法保存消息");
return;
}
// 创建ChatMessageBo对象
ChatMessageBo messageBO = new ChatMessageBo();
messageBO.setUserId(userId);
messageBO.setSessionId(chatRequest.getSessionId());
messageBO.setContent(content);
messageBO.setRole(role);
messageBO.setModelName(chatRequest.getModel());
messageBO.setRemark(null);
chatMessageService.insertByBo(messageBO);
} catch (Exception e) {
log.error("保存{}聊天消息时出错: {}", getProviderName(), e.getMessage(), e);
}
}
/**
* 获取服务提供商名称
*/
protected String getProviderName(){
return "默认工作流大模型";
}
}

View File

@@ -1,87 +0,0 @@
package org.ruoyi.common.chat.service.chatMessage;
import org.ruoyi.common.chat.domain.bo.chat.ChatMessageBo;
import org.ruoyi.common.chat.domain.dto.ChatMessageDTO;
import org.ruoyi.common.chat.domain.vo.chat.ChatMessageVo;
import org.ruoyi.common.mybatis.core.page.PageQuery;
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
import java.util.Collection;
import java.util.List;
/**
* 聊天消息Service接口
*
* @author ageerle
* @date 2025-12-14
*/
public interface IChatMessageService {
/**
* 查询聊天消息
*
* @param id 主键
* @return 聊天消息
*/
ChatMessageVo queryById(Long id);
/**
* 分页查询聊天消息列表
*
* @param bo 查询条件
* @param pageQuery 分页参数
* @return 聊天消息分页列表
*/
TableDataInfo<ChatMessageVo> queryPageList(ChatMessageBo bo, PageQuery pageQuery);
/**
* 查询符合条件的聊天消息列表
*
* @param bo 查询条件
* @return 聊天消息列表
*/
List<ChatMessageVo> queryList(ChatMessageBo bo);
/**
* 新增聊天消息
*
* @param bo 聊天消息
* @return 是否新增成功
*/
Boolean insertByBo(ChatMessageBo bo);
/**
* 修改聊天消息
*
* @param bo 聊天消息
* @return 是否修改成功
*/
Boolean updateByBo(ChatMessageBo bo);
/**
* 校验并批量删除聊天消息信息
*
* @param ids 待删除的主键集合
* @param isValid 是否进行有效性校验
* @return 是否删除成功
*/
Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
/**
* 根据会话ID获取所有消息
* 用于长期记忆功能
*
* @param sessionId 会话ID
* @return 消息DTO列表
*/
List<ChatMessageDTO> getMessagesBySessionId(Long sessionId);
/**
* 根据会话ID删除所有消息
* 用于清理会话历史
*
* @param sessionId 会话ID
* @return 是否删除成功
*/
Boolean deleteBySessionId(Long sessionId);
}

View File

@@ -2,9 +2,11 @@ package org.ruoyi.common.sse.core;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.core.utils.SpringUtils;
import org.ruoyi.common.redis.utils.RedisUtils;
import org.ruoyi.common.sse.dto.SseEventDto;
import org.ruoyi.common.sse.dto.SseMessageDto;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@@ -65,7 +67,7 @@ public class SseEmitterManager {
emitter.onCompletion(() -> {
SseEmitter remove = emitters.remove(token);
if (remove != null) {
// remove.complete();
remove.complete();
}
});
emitter.onTimeout(() -> {
@@ -174,9 +176,11 @@ public class SseEmitterManager {
if (MapUtil.isNotEmpty(emitters)) {
for (Map.Entry<String, SseEmitter> entry : emitters.entrySet()) {
try {
// 格式化为标准SSE JSON格式
SseEventDto eventDto = SseEventDto.content(message);
entry.getValue().send(SseEmitter.event()
.name("message")
.data(message));
.data(JSONUtil.toJsonStr(eventDto)));
} catch (Exception e) {
SseEmitter remove = emitters.remove(entry.getKey());
if (remove != null) {
@@ -189,6 +193,33 @@ public class SseEmitterManager {
}
}
/**
* 向指定的用户会话发送结构化事件
*
* @param userId 要发送消息的用户id
* @param eventDto SSE事件对象
*/
public void sendEvent(Long userId, SseEventDto eventDto) {
Map<String, SseEmitter> emitters = USER_TOKEN_EMITTERS.get(userId);
if (MapUtil.isNotEmpty(emitters)) {
for (Map.Entry<String, SseEmitter> entry : emitters.entrySet()) {
try {
entry.getValue().send(SseEmitter.event()
.name(eventDto.getEvent())
.data(JSONUtil.toJsonStr(eventDto)));
} catch (Exception e) {
SseEmitter remove = emitters.remove(entry.getKey());
if (remove != null) {
remove.complete();
}
}
}
} else {
USER_TOKEN_EMITTERS.remove(userId);
}
}
/**
* 本机全用户会话发送消息
*

View File

@@ -0,0 +1,92 @@
package org.ruoyi.common.sse.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
/**
* SSE 事件数据传输对象
* <p>
* 标准的 SSE 消息格式,支持不同事件类型
*
* @author ageerle@163.com
* @date 2025/03/19
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SseEventDto implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 事件类型
*/
private String event;
/**
* 消息内容
*/
private String content;
/**
* 推理内容(深度思考模式)
*/
private String reasoningContent;
/**
* 错误信息
*/
private String error;
/**
* 是否完成
*/
private Boolean done;
/**
* 创建内容事件
*/
public static SseEventDto content(String content) {
return SseEventDto.builder()
.event("content")
.content(content)
.build();
}
/**
* 创建推理内容事件
*/
public static SseEventDto reasoning(String reasoningContent) {
return SseEventDto.builder()
.event("reasoning")
.reasoningContent(reasoningContent)
.build();
}
/**
* 创建完成事件
*/
public static SseEventDto done() {
return SseEventDto.builder()
.event("done")
.done(true)
.build();
}
/**
* 创建错误事件
*/
public static SseEventDto error(String error) {
return SseEventDto.builder()
.event("error")
.error(error)
.build();
}
}

View File

@@ -1,10 +1,12 @@
package org.ruoyi.common.sse.utils;
import java.util.Collections;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.core.utils.SpringUtils;
import org.ruoyi.common.sse.core.SseEmitterManager;
import org.ruoyi.common.sse.dto.SseEventDto;
import org.ruoyi.common.sse.dto.SseMessageDto;
/**
@@ -27,6 +29,7 @@ public class SseMessageUtils {
/**
* 向指定的SSE会话发送消息
* 通过 Redis Pub/Sub 广播,确保跨模块消息可达
*
* @param userId 要发送消息的用户id
* @param message 要发送的消息内容
@@ -35,7 +38,11 @@ public class SseMessageUtils {
if (!isEnable()) {
return;
}
MANAGER.sendMessage(userId, message);
// 通过 Redis 广播,让所有模块的 SseTopicListener 接收并转发到本地 SSE 连接
SseMessageDto dto = new SseMessageDto();
dto.setMessage(message);
dto.setUserIds(Collections.singletonList(userId));
MANAGER.publishMessage(dto);
}
/**
@@ -86,6 +93,58 @@ public class SseMessageUtils {
MANAGER.disconnect(userId, tokenValue);
}
/**
* 向指定的SSE会话发送结构化事件
*
* @param userId 要发送消息的用户id
* @param eventDto SSE事件对象
*/
public static void sendEvent(Long userId, SseEventDto eventDto) {
if (!isEnable()) {
return;
}
MANAGER.sendEvent(userId, eventDto);
}
/**
* 发送内容事件
*
* @param userId 用户ID
* @param content 内容
*/
public static void sendContent(Long userId, String content) {
sendEvent(userId, SseEventDto.content(content));
}
/**
* 发送推理内容事件
*
* @param userId 用户ID
* @param reasoningContent 推理内容
*/
public static void sendReasoning(Long userId, String reasoningContent) {
sendEvent(userId, SseEventDto.reasoning(reasoningContent));
}
/**
* 发送完成事件
*
* @param userId 用户ID
*/
public static void sendDone(Long userId) {
sendEvent(userId, SseEventDto.done());
}
/**
* 发送错误事件
*
* @param userId 用户ID
* @param error 错误信息
*/
public static void sendError(Long userId, String error) {
sendEvent(userId, SseEventDto.error(error));
}
/**
* 是否开启
*/