feat: 发布3.0版本,新增文档处理能力和演示模式

- 升级langchain4j版本至1.13.0
- 新增docx/pdf/xlsx文档处理技能模块
- 添加演示模式配置和切面拦截
- 优化聊天服务和可观测性监听器

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
wangle
2026-04-13 18:02:27 +08:00
parent bf7b5eac72
commit c1fc02894b
100 changed files with 27576 additions and 189 deletions

View File

@@ -0,0 +1,26 @@
package org.ruoyi.common.chat.domain.dto.request;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
/**
* Agent 对话请求对象(简化版)
*
* @author ageerle@163.com
* @date 2025/04/10
*/
@Data
public class AgentChatRequest {
/**
* 对话消息
*/
@NotEmpty(message = "对话消息不能为空")
private String content;
/**
* 会话id可选不传则不保存历史
*/
private Long sessionId;
}

View File

@@ -3,8 +3,13 @@ package org.ruoyi.common.chat.domain.dto.request;
import com.alibaba.fastjson.annotation.JSONField;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import dev.langchain4j.data.message.ChatMessage;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
/**
@@ -62,7 +67,6 @@ public class ChatRequest {
*/
private String appId;
/**
* 对话id(每个聊天窗口都不一样)
*/
@@ -76,8 +80,28 @@ public class ChatRequest {
private Boolean enableThinking = false;
/**
* 是否支持联网
* 对话模型详情
*/
private Boolean enableInternet;
private ChatModelVo chatModelVo;
/**
* 对话事件
*/
private SseEmitter emitter;
/**
* 当前登录用户id
*/
private Long userId;
/**
* 当前登录用户TOKEN
*/
private String tokenValue;
/**
* 完整的上下文
*/
private List<ChatMessage> contextMessages;
}

View File

@@ -1,5 +1,6 @@
package org.ruoyi.common.security.config;
import cn.dev33.satoken.context.SaTokenContextForThreadLocalStaff;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.filter.SaServletFilter;
import cn.dev33.satoken.httpauth.basic.SaHttpBasicUtil;
@@ -49,6 +50,11 @@ public class SecurityConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
// 注册路由拦截器,自定义验证规则
registry.addInterceptor(new SaInterceptor(handler -> {
// 异步线程中 SaToken 上下文不存在,跳过检查
// 这避免了 SSE 流式响应完成后 emitter.complete() 触发的问题
if (SaTokenContextForThreadLocalStaff.getModelBoxOrNull() == null) {
return;
}
AllUrlHandler allUrlHandler = SpringUtils.getBean(AllUrlHandler.class);
// 登录验证 -- 排除多个路径
SaRouter

View File

@@ -202,12 +202,14 @@ public class SseEmitterManager {
public void sendEvent(Long userId, SseEventDto eventDto) {
Map<String, SseEmitter> emitters = USER_TOKEN_EMITTERS.get(userId);
if (MapUtil.isNotEmpty(emitters)) {
log.debug("【SSE发送】userId: {}, emitter数量: {}, event: {}", userId, emitters.size(), eventDto.getEvent());
for (Map.Entry<String, SseEmitter> entry : emitters.entrySet()) {
try {
entry.getValue().send(SseEmitter.event()
.name(eventDto.getEvent())
.data(JSONUtil.toJsonStr(eventDto)));
} catch (Exception e) {
log.error("【SSE发送失败】userId: {}, token: {}, error: {}", userId, entry.getKey(), e.getMessage());
SseEmitter remove = emitters.remove(entry.getKey());
if (remove != null) {
remove.complete();
@@ -215,6 +217,7 @@ public class SseEmitterManager {
}
}
} else {
log.warn("【SSE发送失败】userId: {} 没有活跃的SSE连接, 当前连接用户: {}", userId, USER_TOKEN_EMITTERS.keySet());
USER_TOKEN_EMITTERS.remove(userId);
}
}

View File

@@ -89,4 +89,21 @@ public class SseEventDto implements Serializable {
.error(error)
.build();
}
/**
* 创建 MCP 工具事件
*/
public static SseEventDto mcpTool(String toolName, String status, String result) {
return SseEventDto.builder()
.event("mcp_tool")
.content(buildMcpJson(toolName, status, result))
.build();
}
private static String buildMcpJson(String toolName, String status, String result) {
return String.format("{\"toolName\":\"%s\",\"status\":\"%s\",\"result\":\"%s\"}",
toolName != null ? toolName.replace("\"", "\\\"") : "",
status != null ? status : "",
result != null ? result.replace("\"", "\\\"").replace("\n", "\\n") : "");
}
}

View File

@@ -0,0 +1,86 @@
package org.ruoyi.common.web.aspectj;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.ruoyi.common.core.exception.ServiceException;
import org.ruoyi.common.core.utils.ServletUtils;
import org.ruoyi.common.web.config.properties.DemoProperties;
import java.util.Set;
/**
* 演示模式切面 - 拦截写操作
*
* @author ruoyi
*/
@Slf4j
@Aspect
@RequiredArgsConstructor
public class DemoAspect {
private final DemoProperties demoProperties;
/**
* 需要拦截的 HTTP 方法
*/
private static final Set<String> WRITE_METHODS = Set.of(
"POST", "PUT", "DELETE", "PATCH"
);
/**
* 拦截所有 Controller 的写操作
*/
@Around("execution(* org.ruoyi..controller..*.*(..))")
public Object around(ProceedingJoinPoint point) throws Throwable {
// 未开启演示模式,直接放行
if (!Boolean.TRUE.equals(demoProperties.getEnabled())) {
return point.proceed();
}
HttpServletRequest request = ServletUtils.getRequest();
if (request == null) {
return point.proceed();
}
String method = request.getMethod();
String requestUri = request.getRequestURI();
// 非写操作,放行
if (!WRITE_METHODS.contains(method.toUpperCase())) {
return point.proceed();
}
// 检查排除路径
for (String exclude : demoProperties.getExcludes()) {
if (match(exclude, requestUri)) {
return point.proceed();
}
}
log.info("演示模式拦截写操作: {} {}", method, requestUri);
throw new ServiceException(demoProperties.getMessage());
}
/**
* 路径匹配(支持通配符 * 和 **
*/
private boolean match(String pattern, String path) {
if (pattern.endsWith("/**")) {
String prefix = pattern.substring(0, pattern.length() - 3);
return path.startsWith(prefix);
}
if (pattern.endsWith("/*")) {
String prefix = pattern.substring(0, pattern.length() - 2);
if (!path.startsWith(prefix)) {
return false;
}
String suffix = path.substring(prefix.length());
return suffix.indexOf('/') == -1;
}
return path.equals(pattern);
}
}

View File

@@ -0,0 +1,25 @@
package org.ruoyi.common.web.config;
import org.ruoyi.common.web.aspectj.DemoAspect;
import org.ruoyi.common.web.config.properties.DemoProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 演示模式自动配置
*
* @author ruoyi
*/
@Configuration
@EnableConfigurationProperties(DemoProperties.class)
public class DemoAutoConfiguration {
/**
* 注册演示模式切面
*/
@Bean
public DemoAspect demoAspect(DemoProperties demoProperties) {
return new DemoAspect(demoProperties);
}
}

View File

@@ -0,0 +1,32 @@
package org.ruoyi.common.web.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
/**
* 演示模式 配置属性
*
* @author ruoyi
*/
@Data
@ConfigurationProperties(prefix = "demo")
public class DemoProperties {
/**
* 是否开启演示模式
*/
private Boolean enabled = false;
/**
* 提示消息
*/
private String message = "演示模式,不允许进行写操作";
/**
* 排除的路径(这些路径不受演示模式限制)
*/
private List<String> excludes = new ArrayList<>();
}

View File

@@ -1,4 +1,5 @@
org.ruoyi.common.web.config.CaptchaConfig
org.ruoyi.common.web.config.DemoAutoConfiguration
org.ruoyi.common.web.config.FilterConfig
org.ruoyi.common.web.config.I18nConfig
org.ruoyi.common.web.config.ResourcesConfig