effectiveTools;
if (enableMcpTools && (allowedMcpTools == null || allowedMcpTools.isEmpty())) {
- effectiveTools = java.util.List.of("*");
+ effectiveTools = List.of("*");
} else {
effectiveTools = allowedMcpTools;
}
@@ -205,10 +201,12 @@ public class AssistantApp {
/**
* 流式对话(新入口,委托 {@link ChatPipeline} 编排)。
- * 自动重置 MCP 事件并在流末尾追加工具调用事件行。
+ *
+ * 注意:返回类型为 Flux<String>,Spring WebFlux 会对每个元素做 data: 帧包装。
+ * 因此 Flux 元素必须只是纯文本内容,不应包含 event: / data: 等 SSE 协议行。
*
* @param ctx 对话上下文
- * @return 流式回答
+ * @return 纯文本流式回答(每个元素为一段自然语言文本)
*/
public Flux chatStream(ChatContext ctx) {
// 熔断:全局 AI 调用处于熔断状态
@@ -218,8 +216,8 @@ public class AssistantApp {
}
ChatRequest req = chatPipeline.buildRequest(ctx);
if (req.faqHit()) {
- // FAQ 命中:先发状态事件告知前端,再返回答案
- return Flux.just(STATUS_EVENT_FAQD, req.faqAnswer().get());
+ // FAQ 命中:直接返回答案文本(不附加 SSE 协议行,Spring 会自动做 data: 帧包装)
+ return Flux.just(req.faqAnswer().get());
}
McpToolCallback.resetEvents();
McpToolCallback.resetCallRounds();
@@ -230,9 +228,8 @@ public class AssistantApp {
if (StringUtils.hasText(req.finalSystemPrompt())) {
spec = spec.system(req.finalSystemPrompt());
}
- // 首字节前先发状态事件,让前端感知系统正在生成回复
- Flux statusEvent = Flux.just(STATUS_EVENT_GENERATING);
- return statusEvent.concatWith(appendMcpToolEvents(spec.stream().content()))
+ // 直接返回纯文本流,不附加任何 SSE 协议行
+ return spec.stream().content()
.doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY))
.doOnError(e -> {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
@@ -256,38 +253,5 @@ public class AssistantApp {
return chatPipeline.retrieveSources(ctx);
}
- // ==================== 内部工具方法 ====================
-
- /**
- * 在 SSE 文本流末尾追加 MCP 工具调用事件行。
- * 事件格式遵循 SSE 标准,前端由 readSSEStreamWithEvents 解析。
- */
- private Flux appendMcpToolEvents(Flux contentFlux) {
- return contentFlux.concatMap(chunk -> Flux.just(chunk))
- .concatWith(Flux.defer(() -> {
- java.util.List events = McpToolCallback.drainEvents();
- if (events.isEmpty()) {
- return Flux.empty();
- }
- log.info("本次对话触发了 {} 个 MCP 工具调用", events.size());
- List sseLines = new ArrayList<>();
- for (McpToolCallback.ToolCallEvent event : events) {
- sseLines.add("event:tool_call_result");
- String json = String.format("{\"tool\":\"%s\",\"result\":\"%s\",\"latencyMs\":%d}",
- escapeSse(event.tool()),
- escapeSse(event.result()),
- event.latencyMs());
- sseLines.add("data:" + json);
- sseLines.add(""); // SSE 事件分隔空行
- }
- return Flux.fromIterable(sseLines);
- }));
- }
-
- /** 转义 SSE 数据中的特殊字符 */
- private static String escapeSse(String s) {
- if (s == null) return "";
- return s.replace("\\", "\\\\").replace("\"", "\\\"")
- .replace("\n", "\\n").replace("\r", "\\r");
- }
+ // ==================== 内部工具方法已移除,不再需要 ====================
}
diff --git a/src/main/java/com/wok/supportbot/security/SdkAuthFilter.java b/src/main/java/com/wok/supportbot/security/SdkAuthFilter.java
index d6afb05..9b40324 100644
--- a/src/main/java/com/wok/supportbot/security/SdkAuthFilter.java
+++ b/src/main/java/com/wok/supportbot/security/SdkAuthFilter.java
@@ -13,10 +13,15 @@ import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
+import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -24,11 +29,17 @@ import java.util.UUID;
/**
* SDK 鉴权过滤器
- * 拦截 /ai/** 请求,校验 SDK JWT Token。
+ * 拦截 /ai/** 请求,校验 SDK JWT Token(也兼容管理后台 JWT Token)。
*
* 优先级高于 ApiKeyAuthFilter,在 SecurityFilterChain 之前执行。
* 校验通过后提取 allowedRoleIds 用于角色鉴权。
* 校验失败直接返回 401/403,请求不会到达 AiController。
+ *
+ * 兼容两种 Token:
+ *
+ * - SDK JWT Token — 由 /open-api/auth/token 签发,使用 jwt.sdk-secret 签名
+ * - 管理后台 JWT Token — 由 /auth/login 签发,使用 jwt.secret 签名(管理后台 ChatPanel 使用)
+ *
*/
@Slf4j
@Component
@@ -38,6 +49,9 @@ public class SdkAuthFilter extends OncePerRequestFilter {
@Autowired
private SdkJwtTokenProvider sdkJwtTokenProvider;
+ @Autowired
+ private JwtTokenProvider jwtTokenProvider;
+
private static final ObjectMapper objectMapper = new ObjectMapper();
@Override
@@ -73,23 +87,57 @@ public class SdkAuthFilter extends OncePerRequestFilter {
return;
}
- // ② JWT 签名验证 + ③ 过期检查
- Claims claims;
+ // ② 双通道 Token 验证:先 SDK JWT,再管理后台 JWT
+ Claims claims = null;
+ boolean isSdkToken = false;
+
+ // 通道 A:SDK JWT 验证
try {
claims = sdkJwtTokenProvider.parseToken(token);
+ isSdkToken = true;
} catch (ExpiredJwtException e) {
writeError(response, 401, "令牌已过期");
return;
} catch (JwtException e) {
- log.warn("SDK Token 验证失败: {}", e.getMessage());
- writeError(response, 401, "无效的认证令牌");
- return;
+ // SDK 验证失败,尝试管理后台 JWT(通道 B)
+ log.debug("SDK Token 验证未通过(可能为管理后台 Token),尝试管理后台 JWT: {}", e.getMessage());
+ }
+
+ // 通道 B:管理后台 JWT 验证(仅当 SDK 验证失败时尝试)
+ if (claims == null) {
+ try {
+ if (jwtTokenProvider.validateToken(token)) {
+ // 管理后台 JWT 验证成功,设置 SecurityContext
+ String username = jwtTokenProvider.getUsernameFromToken(token);
+ List roles = jwtTokenProvider.getRolesFromToken(token);
+ List authorities = roles.stream()
+ .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
+ .toList();
+ UsernamePasswordAuthenticationToken authentication =
+ new UsernamePasswordAuthenticationToken(username, null, authorities);
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ log.debug("管理后台 JWT 认证成功: path={}, user={}, roles={}",
+ request.getServletPath(), username, roles);
+ chain.doFilter(request, response);
+ return;
+ } else {
+ log.warn("管理后台 JWT 验证失败: path={}", request.getServletPath());
+ writeError(response, 401, "无效的认证令牌");
+ return;
+ }
+ } catch (Exception e) {
+ log.warn("管理后台 JWT 验证异常: path={}, error={}",
+ request.getServletPath(), e.getMessage());
+ writeError(response, 401, "无效的认证令牌");
+ return;
+ }
}
- // ④ 提取 allowedRoleIds
+ // SDK Token 验证成功后的逻辑
+ // ③ 提取 allowedRoleIds
Set allowedRoleIds = sdkJwtTokenProvider.getAllowedRoleIds(claims);
- // ⑤ roleId ∈ allowedRoleIds 校验(仅当请求携带 roleId 参数时)
+ // ④ roleId ∈ allowedRoleIds 校验(仅当请求携带 roleId 参数时)
String roleIdParam = request.getParameter("roleId");
if (roleIdParam != null && !roleIdParam.isEmpty()) {
try {
diff --git a/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java b/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java
index 88829cb..a124b86 100644
--- a/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java
+++ b/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java
@@ -37,8 +37,8 @@ public class SdkJwtTokenProvider {
/** 5 分钟下限 */
private static final long MIN_EXPIRATION = 300000L;
- /** 默认密钥标识(禁止使用) */
- private static final String DEFAULT_SECRET = "support-bot-sdk-jwt-secret-2026-please-change";
+ /** 默认密钥标识(禁止使用),长度 ≥64 字符以兼容 HS256/HS384/HS512 */
+ private static final String DEFAULT_SECRET = "support-bot-sdk-jwt-secret-2026-please-change-to-a-strong-random-value";
public SdkJwtTokenProvider(
@Value("${jwt.sdk-secret:support-bot-sdk-jwt-secret-2026-please-change}") String sdkSecret,
diff --git a/src/main/java/com/wok/supportbot/security/SecurityConfig.java b/src/main/java/com/wok/supportbot/security/SecurityConfig.java
index 281f942..926d3bc 100644
--- a/src/main/java/com/wok/supportbot/security/SecurityConfig.java
+++ b/src/main/java/com/wok/supportbot/security/SecurityConfig.java
@@ -8,6 +8,7 @@ import org.springframework.security.config.annotation.authentication.configurati
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@@ -60,9 +61,6 @@ public class SecurityConfig {
// SDK 专属会话接口(在 /ai/** 路径下,含账户归属校验)
// Knife4j / Swagger
.requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll()
- // 静态资源
- .requestMatchers("/sdk/**", "/css/**", "/js/**", "/components/**").permitAll()
- .requestMatchers("/index.html", "/", "/favicon.ico").permitAll()
// ========== 管理接口(需要认证) ==========
.anyRequest().authenticated()
@@ -83,6 +81,20 @@ public class SecurityConfig {
return http.build();
}
+ @Bean
+ public WebSecurityCustomizer webSecurityCustomizer() {
+ return web -> web.ignoring().requestMatchers(
+ // HTML 首页
+ "/", "/index.html",
+ // favicon
+ "/favicon.ico", "/favicon.svg",
+ // Vite 构建产物
+ "/assets/**",
+ // SDK 静态资源
+ "/sdk/**"
+ );
+ }
+
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
diff --git a/src/main/java/com/wok/supportbot/service/DocumentService.java b/src/main/java/com/wok/supportbot/service/DocumentService.java
index 2001b2a..40f4a25 100644
--- a/src/main/java/com/wok/supportbot/service/DocumentService.java
+++ b/src/main/java/com/wok/supportbot/service/DocumentService.java
@@ -414,7 +414,9 @@ public class DocumentService {
*/
public List