attributes) {
- try {
- // 1. 获取当前登录用户与 Token
- LoginUser loginUser = LoginHelper.getLoginUser();
- String tokenValue = StpUtil.getTokenValue();
-
- // 2. 未登录直接拒绝握手
- if (loginUser == null || StringUtils.isBlank(tokenValue)) {
- return false;
- }
-
- // 3. 校验客户端ID(防止多端冒用)
- String headerCid = request.getHeaders().getFirst(LoginHelper.CLIENT_KEY);
- String paramCid = UriComponentsBuilder.fromUri(request.getURI())
- .build()
- .getQueryParams()
- .getFirst(LoginHelper.CLIENT_KEY);
- Object clientExtra = StpUtil.getExtra(LoginHelper.CLIENT_KEY);
-
- // 客户端ID必须与请求头/参数中的一致,否则拒绝连接
- if (clientExtra == null || !StringUtils.equalsAny(clientExtra.toString(), headerCid, paramCid)) {
- throw NotLoginException.newInstance(StpUtil.getLoginType(),
- "-100", "客户端ID与Token不匹配",
- StpUtil.getTokenValue());
- }
-
- // 4. 认证通过,将用户信息存入会话属性,供后续 WebSocketHandler 使用
- attributes.put(MessageConstants.LOGIN_USER_KEY, loginUser);
- attributes.put(MessageConstants.LOGIN_TOKEN_KEY, tokenValue);
- return true;
- } catch (NotLoginException e) {
- // 认证失败,记录日志并拒绝连接
- log.error("WebSocket 认证失败'{}',无法访问系统资源", e.getMessage());
- return false;
- }
+ LoginUser loginUser = LoginHelper.getLoginUser();
+ String tokenValue = StpUtil.getTokenValue();
+ attributes.put(MessageConstants.LOGIN_USER_KEY, loginUser);
+ attributes.put(MessageConstants.LOGIN_TOKEN_KEY, tokenValue);
+ return true;
}
/**
diff --git a/ruoyi-common/ruoyi-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java b/ruoyi-common/ruoyi-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java
index 45fee93b6..d43f6eddb 100644
--- a/ruoyi-common/ruoyi-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java
+++ b/ruoyi-common/ruoyi-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java
@@ -3,12 +3,14 @@ package org.dromara.common.security.config;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.NotPermissionException;
import cn.dev33.satoken.filter.SaServletFilter;
+import cn.dev33.satoken.filter.SaTokenContextFilterForJakartaServlet;
import cn.dev33.satoken.httpauth.basic.SaHttpBasicUtil;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import cn.dev33.satoken.util.SaTokenConsts;
+import jakarta.servlet.DispatcherType;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
@@ -21,13 +23,15 @@ import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.common.security.config.properties.SecurityProperties;
import org.dromara.common.security.handler.AllUrlHandler;
-import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
+import org.springframework.core.Ordered;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+import java.util.EnumSet;
import java.util.List;
/**
@@ -45,8 +49,28 @@ public class SecurityConfig implements WebMvcConfigurer {
private static final String CLIENT_RULE_SEPARATOR_REGEX = "[,;\\r\\n]+";
private final SecurityProperties securityProperties;
- @Value("${message.path:/resource/message}")
- private String messagePath;
+
+ /**
+ * 重新注册 Sa-Token 上下文过滤器,使其覆盖 Servlet 异步分发。
+ *
+ * SSE、WebSocket 握手等场景可能触发 ASYNC/ERROR dispatcher,如果上下文过滤器只处理普通 REQUEST,
+ * 后续统一鉴权或业务代码读取 SaHolder/StpUtil 时会出现 SaTokenContext 未初始化。
+ *
+ * @param filter Sa-Token 官方上下文过滤器
+ * @return 过滤器注册配置
+ */
+ @Bean
+ public FilterRegistrationBean saTokenContextFilterRegistration(
+ SaTokenContextFilterForJakartaServlet filter) {
+ FilterRegistrationBean registration = new FilterRegistrationBean<>();
+ registration.setFilter(filter);
+ registration.setName("saTokenContextFilterForServlet");
+ registration.addUrlPatterns("/*");
+ registration.setDispatcherTypes(EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC, DispatcherType.ERROR));
+ registration.setAsyncSupported(true);
+ registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
+ return registration;
+ }
/**
* 注册 Sa-Token 路由拦截器并配置鉴权规则。
@@ -91,8 +115,7 @@ public class SecurityConfig implements WebMvcConfigurer {
});
})).addPathPatterns("/**")
// 排除不需要拦截的路径
- .excludePathPatterns(securityProperties.getExcludes())
- .excludePathPatterns(messagePath);
+ .excludePathPatterns(securityProperties.getExcludes());
}
/**
diff --git a/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/SnailAiController.java b/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/SnailAiController.java
index 5d6baba14..871fef862 100644
--- a/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/SnailAiController.java
+++ b/ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/SnailAiController.java
@@ -19,6 +19,7 @@ import com.aizuda.snail.ai.openapi.client.core.api.OpenApiChatClient;
import com.aizuda.snail.ai.openapi.client.core.api.OpenApiConversationClient;
import com.aizuda.snail.ai.openapi.client.core.api.OpenApiUserClient;
import com.aizuda.snail.ai.openapi.client.core.listener.SseEventListener;
+import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@@ -190,7 +191,9 @@ public class SnailAiController extends BaseController {
@PostMapping(value = "/agent/{agentId}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStream(
@NotNull(message = "智能体ID不能为空") @PathVariable Long agentId,
- @RequestBody OpenApiChatRequest request) {
+ @RequestBody OpenApiChatRequest request,
+ HttpServletResponse response) {
+ prepareSseResponse(response);
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT);
AtomicBoolean closed = new AtomicBoolean(false);
emitter.onTimeout(() -> {
@@ -303,6 +306,18 @@ public class SnailAiController extends BaseController {
}
}
+ /**
+ * 设置 SSE 响应头,覆盖统一鉴权成功路径中的默认 JSON 响应类型。
+ *
+ * @param response 当前响应
+ */
+ private void prepareSseResponse(HttpServletResponse response) {
+ response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE);
+ response.setCharacterEncoding("UTF-8");
+ response.setHeader("Cache-Control", "no-cache");
+ response.setHeader("X-Accel-Buffering", "no");
+ }
+
/**
* 获取当前登录用户对应的 openId,不存在时会自动注册。
*/
diff --git a/script/sql/ry_ai.sql b/script/sql/ry_ai.sql
index adeec46d1..dd6125fb4 100644
--- a/script/sql/ry_ai.sql
+++ b/script/sql/ry_ai.sql
@@ -190,7 +190,10 @@ INSERT IGNORE INTO snail_ai_model_provider (provider_name, provider_key, descrip
VALUES ('OpenAI', 'openai', 'OpenAI官方模型 (GPT-4, GPT-3.5等)', 1),
('Claude', 'claude', 'Anthropic Claude模型', 1),
('Ollama', 'ollama', '本地开源模型 (Llama, Mistral等)', 1),
- ('Google Gemini', 'gemini', 'Google Gemini模型', 1);
+ ('Google Gemini', 'gemini', 'Google Gemini模型', 1),
+ ('阿里云百炼', 'qwen', '阿里云百炼 OpenAI 兼容模型 (Qwen等)', 1),
+ ('DeepSeek', 'deepseek', 'DeepSeek OpenAI 兼容模型', 1),
+ ('智谱AI', 'zhipu', '智谱AI OpenAI 兼容模型 (GLM等)', 1);
-- ============================================
-- 智能体相关表