Commit 5f364ac1 authored by ligaowei's avatar ligaowei

refactor(react): 优化DefaultReactExecutor系统提示文本为中文详细说明

- 将系统默认提示文本改为中文,详细描述ReAct框架核心原则与执行流程
- 明确严格串行调用工具链及ReAct迭代闭环的核心规则
- 详细列举工具协同策略和标准化回复格式以提升工具协作效率
- 增加ReAct循环终止及容错机制说明,确保逻辑严谨且易于追踪

fix(service): 优化流式请求完成处理与SSE连接关闭逻辑

- 在CompletionHandlerService中对Emitter完成状态进行判断,避免重复关闭
- StreamRequestService中增加CompletionHandlerService调用保护,防止NoClassDefFoundError异常
- 提供默认发送完成事件和安全完成Emitter的辅助方法,增强稳定性
- UserSseService新增isEmitterCompleted方法,避免冗余事件发送和异常
- 关闭Emitter时增加异常捕获与日志记录,保障连接正确释放

fix(security): 加强SSE端点的权限校验及错误响应处理

- SseAuthorizationFilter中引入AgentService,验证Agent存在与用户访问权限
- 针对未授权、Agent不存在及访问拒绝,发送符合SSE格式的错误事件提示
- 优化全局异常处理GlobalExceptionHandler,对SSE端点访问拒绝情况返回SSE错误数据,避免二次异常
- 细化AccessDeniedException处理逻辑,对响应提交状态进行检查,保证安全输出

feat(tool/hisense): 增强SSO登录工具URL跳转等待逻辑和MFA流程

- 新增等待多个可能成功跳转URL的方法,提升登录成功跳转的容错性
- 增加针对特定URL更灵活的等待匹配方法
- 将等待URL超时延长至60秒,MFA等待时间延长至45秒,提高稳定性
- MFA验证流程改为轮询方式检测页面跳转,增强对超时和异常场景的兼容性
- 登录后结果判断更全面,支持多种登录成功页面URL和状态处理,完善登录状态更新机制

refactor(controller): 移除AgentChatController内重复权限检查

- 注释说明权限校验已由SseAuthorizationFilter完成
- 避免在流式响应开始后抛出异常导致响应状态异常
- 保证流式对话请求的权限安全与流程顺畅
parent 198ca2f1
...@@ -27,70 +27,81 @@ import java.util.function.Consumer; ...@@ -27,70 +27,81 @@ import java.util.function.Consumer;
public class DefaultReactExecutor implements ReactExecutor { public class DefaultReactExecutor implements ReactExecutor {
private static final String DEFAULT_SYSTEM_PROMPT = private static final String DEFAULT_SYSTEM_PROMPT =
"You are a powerful AI assistant powered by the ReAct (Reasoning + Acting) framework. Your task is to solve complex user queries by:\n" + "你是一个基于ReAct(推理+行动)框架构建的高性能专业AI助手,原生集成Spring AI生态体系。你的核心任务是通过严谨的逻辑推理解决复杂、精准的用户查询,严格遵循「工具串行调用」和「迭代循环思考」两大核心准则。\n\n" +
"1. Breaking down problems into clear thinking steps\n" + "你必须严格遵守的核心执行原则:\n" +
"2. Intelligently selecting and combining the right tools to solve each sub-problem\n" + "1. 将复杂问题拆解为具备逻辑依赖关系的、清晰分层的子问题\n" +
"3. Processing tool results and iterating until you reach the final answer\n\n" + "2. 智能选择专业工具并进行**严格串行组合调用** —— 仅当上一个工具完全执行并返回有效结果后,才能启动下一个工具的调用流程,禁止并行/无序调用\n" +
"=== TOOL SYNERGY STRATEGY ===\n" + "3. 深度处理工具执行结果,循环运行「思考→行动→观察」的ReAct闭环,持续迭代修正答案,直至得出精准、满意的结果\n" +
"You have access to multiple specialized tools. You should automatically think about tool combinations that create value:\n" + "4. 严格循环次数限制:ReAct迭代循环**绝对不能超过10轮**,避免无限执行;达到次数上限时,立即终止循环并整合最终答案\n\n" +
"- Chain tools together when one tool's output feeds into another tool's input\n" + "=== 工具协同策略 & 严格串行调用规则 ===\n" +
"- Use text processing tools to prepare data before saving with file tools\n" + "你可调用全套Spring AI专业工具,优先级绝对遵循「串行工具链」,所有工具组合必须符合核心规则:**上一个工具的输出 = 下一个工具的输入**。你需要通过有序、协同的工具调用创造价值,具体规则如下:\n" +
"- Use calculation tools within larger workflows to perform computations\n" + "- 针对有逻辑依赖的任务,按串行顺序串联工具:数据准备 → 数据处理 → 计算分析 → 结果存储/输出\n" +
"- Combine multiple tools to enrich data and provide better insights\n" + "- 先使用文本处理工具清洗、格式化原始数据,再将处理后的数据输入分析/计算类工具\n" +
"- Use data extraction tools with analysis tools for comprehensive results\n\n" + "- 在串行工作流中嵌入计算工具,为子问题执行针对性的精准运算\n" +
"Examples of tool synergy:\n" + "- 串行组合多个工具,逐层丰富数据维度,挖掘深度业务洞察\n" +
"1. Process a file → analyze content with text tools → save results\n" + "- 串行搭配「数据提取工具+分析工具」,得到完整且经过验证的结果\n" +
"2. Get current date/time → combine with formatting tools → format for display\n" + "- 若某工具返回空值/无效结果,暂停当前工具链,为该子问题重新选择备选工具,执行成功后再继续串行调用\n\n" +
"3. Extract web content → process text → calculate statistics → generate charts\n" + "经典串行工具协同场景(优先级参考):\n" +
"4. Perform calculations → convert to specific format → store results\n\n" + "1. 读取文件(Spring AI 文件工具)→ 文本内容解析 → 关键信息提取 → 保存处理结果(全串行、分步执行)\n" +
"=== ReAct THINKING PROCESS ===\n" + "2. 获取当前日期/时间 → 日期格式化工具 → 时区转换 → 格式化展示输出(串行依赖关系)\n" +
"Follow this step-by-step process for every problem:\n\n" + "3. 网页内容提取 → 文本清洗与切分 → 统计计算 → 可视化图表生成(串行数据流)\n" +
"Step 1 - THOUGHT: Analyze the user's query\n" + "4. 数值运算 → 单位转换 → 数据格式标准化 → 结果持久化存储(串行完整流程)\n" +
" - Break down the problem into sub-tasks\n" + "5. 问题拆解 → 关键词提取 → 知识库检索 → 结果验证 → 最终答案整合(串行推理链)\n\n" +
" - Identify which tools or tool combinations could solve each sub-task\n" + "=== 可迭代的ReAct循环思考流程(核心规则,≤ 10轮) ===\n" +
" - Consider tool synergies: can output from one tool feed into another?\n" + "此流程为**循环迭代模式**,非一次性线性步骤。重复执行「思考→行动→观察」闭环,直至得出满意答案、精准结果,或达到10轮上限,每一次循环都需清晰标注。\n" +
" - Plan the sequence of tool calls if multiple tools are needed\n\n" + "每一轮ReAct循环,均严格遵循以下3个核心步骤:\n\n" +
"Step 2 - ACTION: Execute the planned tools\n" + "步骤1 - 思考 (迭代轮次 X/10):全面的问题与结果分析\n" +
" - Call the most appropriate tool(s) based on your analysis\n" + " - 分析用户的核心诉求,拆解为具备逻辑顺序的分层子问题\n" +
" - Tools are executed automatically by the Spring AI framework\n" + " - 针对本轮迭代:评估最新的工具返回结果(如有),校验结果的完整性与准确性\n" +
" - Multiple tools can be invoked in sequence if needed\n" + " - 识别未完成的子任务、或需要修正的错误推理点\n" +
" - Wait for tool execution results before proceeding\n\n" + " - 为当前子任务选择**唯一最合适**的工具(串行规则:每一次行动步骤仅调用一个工具)\n" +
"Step 3 - OBSERVATION: Analyze tool results\n" + " - 确认所选工具的输入来源:要么是用户原始查询,要么是上一次调用工具的输出结果\n" +
" - Examine the data returned from tools\n" + " - 仅规划下一步动作,不做多步预规划,适配迭代修正的需求\n\n" +
" - Verify accuracy and completeness\n" + "步骤2 - 行动 (迭代轮次 X/10):单工具串行调用\n" +
" - Consider if additional tools are needed to enrich or validate the data\n" + " - 本步骤**仅调用一个专业工具**(严格串行原则,禁止并行调用多个工具)\n" +
" - Identify patterns or insights from the results\n\n" + " - 所有工具均由Spring AI框架自动执行,你只需专注于选择正确的工具、定义合法的入参即可\n" +
"Step 4 - FINAL ANSWER: Synthesize and respond\n" + " - 若上一个工具返回无效/空结果:为当前子任务切换备选工具并重新调用\n" +
" - Combine all tool results into a coherent answer\n" + " - 若工具调用失败:记录失败信息,基于已有有效数据继续后续迭代\n\n" +
" - Present information clearly and logically\n" + "步骤3 - 观察 (迭代轮次 X/10):深度结果解读与有效性验证\n" +
" - If a tool combination provided better insights, explain how the tools worked together\n" + " - 解析Spring AI工具执行后返回的完整原始结果\n" +
" - Provide actionable conclusions based on the gathered information\n\n" + " - 对照当前子任务的目标,验证结果的准确性、有效性与完整性\n" +
"=== RESPONSE FORMAT ===\n" + " - 从工具结果中提取核心数据、规律特征与可落地的洞察\n" +
"When responding, follow this structure:\n\n" + " - 判定:当前结果是否足够解决该子任务?是 → 在下一轮循环中推进下一个子任务;否 → 在下一轮思考步骤中重新选择工具\n" +
"1. Thought: Explain your problem analysis and tool selection strategy\n" + " - 判定:整体问题是否已解决?是 → 退出ReAct循环并整合最终答案;否 → 启动下一轮ReAct迭代(X+1)\n\n" +
" - What sub-problems did you identify?\n" + "循环终止核心规则(满足任一即终止):\n" +
" - Which tools address each sub-problem?\n" + " ✔ 当用户的查询被完整、精准解答时,终止循环并整合答案\n" +
" - What is the tool execution sequence?\n\n" + " ✔ 当ReAct迭代轮次达到10轮(上限),立即终止循环\n" +
"2. Action: Describe which tools you're calling and why\n" + " ✔ 当剩余子任务无可用有效工具时,终止循环\n\n" +
" - Tool_Call: 1.[tool name] → Purpose: [why you're using it]\n" + "=== 标准化回复格式(强制要求,不可偏离) ===\n" +
" - Tool_Call: 2.[tool name] → Purpose: [how it complements Tool 1]\n" + "你必须遵循以下固定结构输出所有回复,标注清晰、逻辑连贯;思考、行动和观察可以迭代出现多次,但最终答案只在最后一轮迭代中最后出现:\n\n" +
" - Tool_Call: N.[tool name] → Purpose: [final enrichment or validation]\n\n" + "1. 思考:阐述你的问题拆解思路、迭代推理逻辑,以及串行工具选择策略\n" +
"3. Observation: Interpret the results\n" + " - 拆解后的具备逻辑依赖的分层子问题\n" +
" - What did each tool reveal?\n" + " - 为各子任务选定的工具,以及严格的串行调用顺序\n" +
" - How do the tool results relate to each other?\n" + " - 当前的ReAct迭代轮次(X/10)与本轮核心目标\n" +
" - What patterns or insights emerged from combining tools?\n\n" + " - 说明上一个工具的输出如何作为下一个工具的输入\n\n" +
"4. Final_Answer: Provide the solution\n" + "2. 行动:描述本轮迭代的**单次串行工具调用**(仅限一个工具)\n" +
" - Clear, natural language answer to the user's question\n" + " - 工具调用: [当前轮次 X] → [工具全称] → 核心目的: [调用该工具的明确原因、输入来源、预期输出]\n\n" +
" - Highlight insights gained from tool combinations\n" + "3. 观察:解读来自Spring AI的真实工具执行结果(严禁编造数据)\n" +
" - Offer follow-up insights or recommendations\n\n" + " - 被调用工具返回的真实有效结果\n" +
"=== CRITICAL RULES ===\n" + " - 针对当前子任务,验证结果的准确性与完整性\n" +
"- ALWAYS think about tool combinations first - complex problems usually need multiple tools\n" + " - 从结果中提炼的核心洞察与规律\n" +
"- DO NOT limit yourself to single tools - maximize tool synergy\n" + " - 决策结果:继续迭代 / 终止循环(需说明明确原因)\n\n" +
"- ALWAYS wait for actual tool execution results, never make up data\n" + "4. 最终答案:将所有迭代结果整合为连贯、精准、具备落地性的答案\n" +
"- Tools are called automatically by Spring AI - focus on selecting the right tools\n" + " - 用通顺的自然语言直接回答用户的核心问题\n" +
"- Be explicit about your tool selection strategy in the Thought section\n" + " - 重点提炼通过「串行工具协同」和「ReAct迭代推理」得到的核心洞察\n" +
"- If a tool is unavailable, explain what additional capabilities you would need\n" + " - 明确标注本次解答所用的ReAct迭代轮次(X/10)\n" +
"- Maintain conversational tone in Final Answer, not technical tool details"; " - 提供可落地的后续建议或补充洞察(如有必要)\n" +
" - 保持专业的对话语气,面向终端用户时规避技术化的工具术语\n\n" +
"=== 不可违背的核心硬性规则(全部遵守) ===\n" +
"- 串行优先:所有工具均严格串行调用,绝不并行;下一个工具的启动,必须等待上一个工具执行完成\n" +
"- 迭代思考:ReAct是循环流程而非一次性步骤,通过迭代修正答案、纠正错误\n" +
"- 次数限制:ReAct迭代最多10轮,无例外\n" +
"- 拒绝编造:始终使用Spring AI返回的真实工具执行结果,绝不虚构数据,也绝不编造结果,也绝不在工具执行结果基础上编造\n" +
"- Spring AI原生适配:工具由框架自动执行,你只需专注工具选择与入参定义\n" +
"- 策略透明:在「思考」环节清晰说明你的串行工具选择与迭代推理逻辑\n" +
"- 容错处理:工具不可用/调用失败时,说明所需的备选能力,并基于已有有效数据继续推进\n" +
"- 结果校验:对无效/空的工具结果进行驳回,并为当前子任务重新选择工具\n" +
"- 可追溯:所有ReAct迭代均清晰标注轮次,实现完整的推理链路可追溯";
private final List<ReactCallback> reactCallbacks = new ArrayList<>(); private final List<ReactCallback> reactCallbacks = new ArrayList<>();
private final AtomicInteger stepCounter = new AtomicInteger(0); private final AtomicInteger stepCounter = new AtomicInteger(0);
......
...@@ -112,8 +112,13 @@ public class CompletionHandlerService { ...@@ -112,8 +112,13 @@ public class CompletionHandlerService {
// 最后才关闭emitter,确保所有操作都完成后再提交响应 // 最后才关闭emitter,确保所有操作都完成后再提交响应
try { try {
// 检查emitter是否已经完成,避免重复关闭
if (!unifiedSseService.isEmitterCompleted(emitter)) {
unifiedSseService.completeEmitter(emitter, isCompleted); unifiedSseService.completeEmitter(emitter, isCompleted);
log.debug("SSE Emitter已关闭"); log.debug("SSE Emitter已关闭");
} else {
log.debug("SSE Emitter已完成,跳过关闭操作");
}
} catch (Exception e) { } catch (Exception e) {
log.error("关闭Emitter时发生错误", e); log.error("关闭Emitter时发生错误", e);
} }
......
...@@ -130,25 +130,59 @@ public class StreamRequestService { ...@@ -130,25 +130,59 @@ public class StreamRequestService {
try { try {
// 使用CompletionHandlerService处理完成回调 // 使用CompletionHandlerService处理完成回调
if (completionHandlerService != null) { if (completionHandlerService != null) {
// 添加对CompletionHandlerService调用的额外保护,防止NoClassDefFoundError
try {
completionHandlerService.handleCompletion(emitter, processor, agent, request, userId, fullContent, isCompleted); completionHandlerService.handleCompletion(emitter, processor, agent, request, userId, fullContent, isCompleted);
} catch (NoClassDefFoundError e) {
log.error("CompletionHandlerService依赖类未找到,使用默认处理逻辑: {}", e.getMessage());
// 如果类未找到,使用默认逻辑完成emitter
sendCompletionAndCompleteEmitter();
}
} else { } else {
// 如果completionHandlerService不可用,使用默认处理逻辑 // 如果completionHandlerService不可用,使用默认处理逻辑
sendCompletionAndCompleteEmitter();
}
} catch (Exception e) {
log.error("处理完成事件失败", e);
// 确保即使出现异常也完成emitter
try {
sendCompletionAndCompleteEmitter();
} catch (Exception ex) {
log.error("在异常处理路径中完成emitter也失败", ex);
// 最终保障:直接完成emitter,避免连接未关闭
try {
emitter.complete();
} catch (Exception finalEx) {
log.error("最终尝试完成emitter也失败", finalEx);
}
}
}
}
/**
* 安全地发送完成事件并完成emitter
* 避免重复完成和异常情况
* 注意:此方法不调用onComplete,避免循环调用
*/
private void sendCompletionAndCompleteEmitter() {
try { try {
// 发送完成事件 // 发送完成事件
emitter.send("[DONE]"); emitter.send("[DONE]");
// 完成 emitter // 完成 emitter - 直接完成,不再通过CompletionHandlerService重复调用onComplete
try {
emitter.complete(); emitter.complete();
} catch (Exception ex) {
log.error("完成emitter时发生错误", ex);
}
} catch (Exception e) { } catch (Exception e) {
log.error("处理完成事件失败", e); log.error("发送完成事件时发生错误", e);
} }
} catch (Exception e) { // 尝试直接完成emitter
log.error("处理完成事件失败", e);
// 确保即使出现异常也完成emitter
try { try {
emitter.complete(); emitter.complete();
} catch (Exception ex) { } catch (Exception ex) {
log.error("完成emitter时发生错误", ex); log.error("完成emitter时发生错误", ex);
} }
} }
} } } }
......
...@@ -355,7 +355,16 @@ public class UserSseService { ...@@ -355,7 +355,16 @@ public class UserSseService {
} }
if (emitter != null) { if (emitter != null) {
try { try {
// 检查emitter是否已经完成,避免重复关闭
if (isEmitterCompleted(emitter)) {
log.debug("Emitter已经完成,跳过关闭操作");
return;
}
emitter.complete(); emitter.complete();
log.debug("Emitter已成功关闭");
} catch (IllegalStateException e) {
log.debug("Emitter已经关闭: {}", e.getMessage());
} catch (Exception e) { } catch (Exception e) {
log.warn("完成Emitter时发生异常: {}", e.getMessage()); log.warn("完成Emitter时发生异常: {}", e.getMessage());
} }
...@@ -377,6 +386,7 @@ public class UserSseService { ...@@ -377,6 +386,7 @@ public class UserSseService {
// 检查逻辑,仅通过尝试发送ping事件来验证连接状态 // 检查逻辑,仅通过尝试发送ping事件来验证连接状态
try { try {
// 尝试发送一个空事件来检查连接状态 // 尝试发送一个空事件来检查连接状态
// 注意:这个方法会实际发送一个事件到客户端,这可能不是理想的方式
emitter.send(SseEmitter.event().name("ping").data("").build()); emitter.send(SseEmitter.event().name("ping").data("").build());
return true; return true;
} catch (Exception ex) { } catch (Exception ex) {
...@@ -385,6 +395,35 @@ public class UserSseService { ...@@ -385,6 +395,35 @@ public class UserSseService {
} }
} }
/**
* 检查SSE Emitter是否已经完成
* 使用更安全的方式检查完成状态,不发送实际事件
*
* @param emitter 要检查的emitter
* @return 如果已完成返回true,否则返回false
*/
public boolean isEmitterCompleted(SseEmitter emitter) {
if (emitter == null) {
return true; // 认为null emitter是已完成的
}
try {
// 尝试发送一个空事件,如果抛出IllegalStateException则表示已关闭
emitter.send(SseEmitter.event());
return false; // 没有异常说明未完成
} catch (IllegalStateException e) {
// 检查错误消息是否包含完成相关的文本
String message = e.getMessage();
if (message != null && (message.contains("completed") || message.contains("closed"))) {
return true;
}
return false;
} catch (Exception e) {
// 其他异常不认为是完成
return false;
}
}
/** /**
* 发送SSE事件 * 发送SSE事件
* 职责:统一发送SSE事件的基础方法 * 职责:统一发送SSE事件的基础方法
......
...@@ -14,6 +14,7 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchExcep ...@@ -14,6 +14,7 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchExcep
import pangea.hiagent.agent.service.ExceptionMonitoringService; import pangea.hiagent.agent.service.ExceptionMonitoringService;
import pangea.hiagent.web.dto.ApiResponse; import pangea.hiagent.web.dto.ApiResponse;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.security.authorization.AuthorizationDeniedException; import org.springframework.security.authorization.AuthorizationDeniedException;
...@@ -212,29 +213,65 @@ public class GlobalExceptionHandler { ...@@ -212,29 +213,65 @@ public class GlobalExceptionHandler {
AuthorizationDeniedException e, HttpServletRequest request) { AuthorizationDeniedException e, HttpServletRequest request) {
log.warn("访问被拒绝: {} - URL: {}", e.getMessage(), request.getRequestURL()); log.warn("访问被拒绝: {} - URL: {}", e.getMessage(), request.getRequestURL());
// 更全面地检查响应是否已经提交 // 检查是否为SSE端点的异常
boolean responseCommitted = false; String requestUri = request.getRequestURI();
boolean isSseEndpoint = requestUri.contains("/api/v1/agent/chat-stream") || requestUri.contains("/api/v1/agent/timeline-events");
// 检查响应是否已经提交
if (request instanceof jakarta.servlet.http.HttpServletRequest) {
jakarta.servlet.http.HttpServletRequest httpRequest = (jakarta.servlet.http.HttpServletRequest) request;
// 获取当前的response对象
jakarta.servlet.http.HttpServletResponse response = null;
if (org.springframework.web.context.request.RequestContextHolder.getRequestAttributes() != null) {
Object nativeResponse = ((org.springframework.web.context.request.ServletWebRequest)
org.springframework.web.context.request.RequestContextHolder
.getRequestAttributes()).getNativeResponse();
if (nativeResponse instanceof jakarta.servlet.http.HttpServletResponse) {
response = (jakarta.servlet.http.HttpServletResponse) nativeResponse;
}
}
// 检查request属性 // 检查响应是否已提交
if (request.getAttribute("jakarta.servlet.error.exception") != null) { if (response != null && response.isCommitted()) {
responseCommitted = true; log.warn("响应已提交,无法发送访问拒绝错误: {}", request.getRequestURL());
// 如果是SSE端点且响应已提交,返回空响应避免二次异常
return ResponseEntity.ok().build();
}
} }
// 检查response是否已提交 // 如果是SSE端点,但响应未提交,发送SSE格式的错误响应
if (request instanceof org.springframework.web.context.request.NativeWebRequest) { if (isSseEndpoint) {
Object nativeResponse = ((org.springframework.web.context.request.NativeWebRequest) request).getNativeResponse(); try {
jakarta.servlet.http.HttpServletResponse response = null;
if (org.springframework.web.context.request.RequestContextHolder.getRequestAttributes() != null) {
Object nativeResponse = ((org.springframework.web.context.request.ServletWebRequest)
org.springframework.web.context.request.RequestContextHolder
.getRequestAttributes()).getNativeResponse();
if (nativeResponse instanceof jakarta.servlet.http.HttpServletResponse) { if (nativeResponse instanceof jakarta.servlet.http.HttpServletResponse) {
if (((jakarta.servlet.http.HttpServletResponse) nativeResponse).isCommitted()) { response = (jakarta.servlet.http.HttpServletResponse) nativeResponse;
responseCommitted = true;
} }
} }
if (response != null) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
// 发送SSE格式的错误事件
response.getWriter().write("event: error\n");
response.getWriter().write("data: {\"error\": \"访问被拒绝,无权限访问该资源\", \"code\": 403, \"timestamp\": " +
System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
log.debug("已发送SSE 访问拒绝错误响应");
} }
// 如果响应已提交,记录日志并返回空响应以避免二次异常 return ResponseEntity.ok().build();
if (responseCommitted) { } catch (Exception ex) {
log.warn("响应已提交,无法发送访问拒绝错误: {}", request.getRequestURL()); log.error("发送SSE访问拒绝错误响应失败", ex);
// 返回空响应而不是build(),避免潜在的响应提交冲突 return ResponseEntity.ok().build();
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null); }
} }
ApiResponse.ErrorDetail errorDetail = ApiResponse.ErrorDetail.builder() ApiResponse.ErrorDetail errorDetail = ApiResponse.ErrorDetail.builder()
......
...@@ -12,6 +12,9 @@ import org.springframework.stereotype.Component; ...@@ -12,6 +12,9 @@ import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import pangea.hiagent.common.utils.JwtUtil; import pangea.hiagent.common.utils.JwtUtil;
import pangea.hiagent.web.service.AgentService;
import pangea.hiagent.model.Agent;
import pangea.hiagent.common.utils.UserUtils;
import java.io.IOException; import java.io.IOException;
import java.util.Collections; import java.util.Collections;
...@@ -30,9 +33,74 @@ public class SseAuthorizationFilter extends OncePerRequestFilter { ...@@ -30,9 +33,74 @@ public class SseAuthorizationFilter extends OncePerRequestFilter {
private static final String TIMELINE_ENDPOINT = "/api/v1/agent/timeline-events"; private static final String TIMELINE_ENDPOINT = "/api/v1/agent/timeline-events";
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
private final AgentService agentService;
public SseAuthorizationFilter(JwtUtil jwtUtil) { public SseAuthorizationFilter(JwtUtil jwtUtil, AgentService agentService) {
this.jwtUtil = jwtUtil; this.jwtUtil = jwtUtil;
this.agentService = agentService;
}
/**
* 发送SSE格式的未授权错误响应
*/
private void sendSseUnauthorizedError(HttpServletResponse response) {
try {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
// 发送SSE格式的错误事件
response.getWriter().write("event: error\n");
response.getWriter().write("data: {\"error\": \"未授权访问,请先登录\", \"code\": 401, \"timestamp\": " +
System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
log.debug("已发送SSE未授权错误响应");
} catch (IOException e) {
log.error("发送SSE未授权错误响应失败", e);
}
}
/**
* 发送SSE格式的Agent不存在错误响应
*/
private void sendSseAgentNotFoundError(HttpServletResponse response) {
try {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
// 发送SSE格式的错误事件
response.getWriter().write("event: error\n");
response.getWriter().write("data: {\"error\": \"Agent不存在\", \"code\": 404, \"timestamp\": " +
System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
log.debug("已发送SSE Agent不存在错误响应");
} catch (IOException e) {
log.error("发送SSE Agent不存在错误响应失败", e);
}
}
/**
* 发送SSE格式的访问拒绝错误响应
*/
private void sendSseAccessDeniedError(HttpServletResponse response) {
try {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
// 发送SSE格式的错误事件
response.getWriter().write("event: error\n");
response.getWriter().write("data: {\"error\": \"访问被拒绝,无权限访问该Agent\", \"code\": 403, \"timestamp\": " +
System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
log.debug("已发送SSE 访问拒绝错误响应");
} catch (IOException e) {
log.error("发送SSE 访问拒绝错误响应失败", e);
}
} }
@Override @Override
...@@ -63,6 +131,40 @@ public class SseAuthorizationFilter extends OncePerRequestFilter { ...@@ -63,6 +131,40 @@ public class SseAuthorizationFilter extends OncePerRequestFilter {
new UsernamePasswordAuthenticationToken(userId, null, authorities); new UsernamePasswordAuthenticationToken(userId, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("SSE端点JWT验证成功,用户: {}", userId); log.debug("SSE端点JWT验证成功,用户: {}", userId);
// 如果是chat-stream端点,需要额外验证agent权限
if (isStreamEndpoint) {
// 从请求参数中获取agentId
String agentId = request.getParameter("agentId");
if (agentId != null) {
try {
Agent agent = agentService.getAgent(agentId);
if (agent == null) {
log.warn("SSE端点访问失败:Agent不存在 - AgentId: {}", agentId);
sendSseAgentNotFoundError(response);
return;
}
// 验证用户是否有权限访问该agent
if (!agent.getOwner().equals(userId) && !UserUtils.isAdminUser(userId)) {
log.warn("SSE端点访问失败:用户 {} 无权限访问Agent: {}", userId, agentId);
sendSseAccessDeniedError(response);
return;
}
log.debug("SSE端点Agent权限验证成功,用户: {}, Agent: {}", userId, agentId);
} catch (Exception e) {
log.error("SSE端点Agent权限验证异常: {}", e.getMessage());
sendSseAccessDeniedError(response);
return;
}
} else {
log.warn("SSE端点请求缺少agentId参数");
sendSseAgentNotFoundError(response);
return;
}
}
// 继续执行过滤器链 // 继续执行过滤器链
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
return; return;
...@@ -83,27 +185,6 @@ public class SseAuthorizationFilter extends OncePerRequestFilter { ...@@ -83,27 +185,6 @@ public class SseAuthorizationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
/**
* 发送SSE格式的未授权错误响应
*/
private void sendSseUnauthorizedError(HttpServletResponse response) {
try {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
// 发送SSE格式的错误事件
response.getWriter().write("event: error\n");
response.getWriter().write("data: {\"error\": \"未授权访问,请先登录\", \"code\": 401, \"timestamp\": " +
System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
log.debug("已发送SSE未授权错误响应");
} catch (IOException e) {
log.error("发送SSE未授权错误响应失败", e);
}
}
/** /**
* 从请求头或参数中提取Token * 从请求头或参数中提取Token
*/ */
......
...@@ -39,6 +39,20 @@ public class HisenseSsoLoginTool { ...@@ -39,6 +39,20 @@ public class HisenseSsoLoginTool {
private static final String SSO_MFA_URL = "https://sso.hisense.com/login/mfaLogin.html"; private static final String SSO_MFA_URL = "https://sso.hisense.com/login/mfaLogin.html";
// 登录成功后可能跳转的URL模式
private static final String[] SUCCESS_REDIRECT_URLS = {
"https://sso.hisense.com/selfcare/?#/profile",
"https://sso.hisense.com/selfcare/",
"https://sso.hisense.com/login/success",
"https://sso.hisense.com/dashboard"
};
// 等待URL超时时间(毫秒),从30秒增加到60秒
private static final int WAIT_FOR_URL_TIMEOUT = 60000;
// 等待URL超时时间(毫秒)用于MFA验证
private static final int MFA_WAIT_FOR_URL_TIMEOUT = 45000;
// 用户名输入框选择器 // 用户名输入框选择器
private static final String USERNAME_INPUT_SELECTOR = "input[placeholder='账号名/海信邮箱/手机号']"; private static final String USERNAME_INPUT_SELECTOR = "input[placeholder='账号名/海信邮箱/手机号']";
...@@ -256,8 +270,12 @@ public class HisenseSsoLoginTool { ...@@ -256,8 +270,12 @@ public class HisenseSsoLoginTool {
performLoginAndUpdateStatus(page); performLoginAndUpdateStatus(page);
// 等待登录完成并重定向回业务系统 // 等待登录完成并重定向回业务系统
page.waitForURL(businessSystemUrl, new Page.WaitForURLOptions().setTimeout(30000)); boolean redirected = waitForUrlWithMultipleOptions(page, new String[]{businessSystemUrl}, WAIT_FOR_URL_TIMEOUT);
if (!redirected) {
log.warn("未能在指定时间内重定向到业务系统页面,当前URL: {}", page.url());
} else {
log.info("登录成功,已重定向回业务系统页面"); log.info("登录成功,已重定向回业务系统页面");
}
} else { } else {
// 即使没有跳转到登录页面,也更新登录时间 // 即使没有跳转到登录页面,也更新登录时间
lastLoginTime = System.currentTimeMillis(); lastLoginTime = System.currentTimeMillis();
...@@ -358,9 +376,19 @@ public class HisenseSsoLoginTool { ...@@ -358,9 +376,19 @@ public class HisenseSsoLoginTool {
return loginResult; return loginResult;
} }
// 等待登录完成并重定向回业务系统 // 等待登录完成并重定向到SSO配置页面,使用更灵活的URL匹配和更长的超时时间
page.waitForURL(SSO_PROFILE_URL, new Page.WaitForURLOptions().setTimeout(30000)); boolean profileRedirected = waitForSpecificUrl(page, SSO_PROFILE_URL, WAIT_FOR_URL_TIMEOUT);
if (!profileRedirected) {
// 如果没有跳转到预期的配置页面,检查是否跳转到了其他可能的登录成功页面
boolean alternativeRedirected = waitForUrlWithMultipleOptions(page, SUCCESS_REDIRECT_URLS, WAIT_FOR_URL_TIMEOUT);
if (!alternativeRedirected) {
log.warn("未能在指定时间内重定向到SSO配置页面,当前URL: {}", page.url());
} else {
log.info("登录成功,已重定向到SSO相关页面");
}
} else {
log.info("登录成功,已重定向回SSO配置页面"); log.info("登录成功,已重定向回SSO配置页面");
}
long endTime = System.currentTimeMillis(); long endTime = System.currentTimeMillis();
log.info("海信SSO登录完成,耗时: {} ms", endTime - startTime); log.info("海信SSO登录完成,耗时: {} ms", endTime - startTime);
...@@ -383,6 +411,142 @@ public class HisenseSsoLoginTool { ...@@ -383,6 +411,142 @@ public class HisenseSsoLoginTool {
} }
} }
/**
* 等待页面跳转到指定的多个URL选项中的任意一个
*
* @param page 要监控的页面
* @param urls 可能的目标URL数组
* @param timeout 超时时间(毫秒)
* @return 是否成功跳转到目标URL之一
*/
private boolean waitForUrlWithMultipleOptions(Page page, String[] urls, int timeout) {
long startTime = System.currentTimeMillis();
long endTime = startTime + timeout;
// 首先检查当前URL是否已经是目标URL之一
String currentUrl = page.url();
for (String url : urls) {
if (currentUrl.equals(url) || currentUrl.startsWith(url)) {
log.info("当前URL已经匹配目标URL: {}", currentUrl);
return true;
}
}
// 监控URL变化直到超时或匹配到目标URL
while (System.currentTimeMillis() < endTime) {
try {
// 等待URL变化 - 使用URL匹配器和超时选项
page.waitForURL(url -> {
// 检查新URL是否匹配目标URL之一
for (String targetUrl : urls) {
if (url.equals(targetUrl) || url.startsWith(targetUrl)) {
log.info("成功跳转到目标URL: {}", url);
return true;
}
}
return false;
}, new Page.WaitForURLOptions().setTimeout(1000)); // 短超时,用于检测变化
// 如果上面的waitForURL成功,说明已经匹配到目标URL
return true;
} catch (com.microsoft.playwright.TimeoutError e) {
// 1秒内URL未变化,继续轮询
String current = page.url();
for (String url : urls) {
if (current.equals(url) || current.startsWith(url)) {
log.info("检测到目标URL: {}", current);
return true;
}
}
} catch (Exception e) {
// 记录异常但继续轮询,因为可能是临时错误
log.debug("等待URL变化时发生异常: {}", e.getMessage());
}
// 短暂休眠以避免过度占用CPU
try {
Thread.sleep(500);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
log.warn("等待URL过程中被中断");
return false;
}
}
log.warn("等待URL超时,当前URL: {},期望URLs: {}", page.url(), String.join(",", urls));
return false;
}
/**
* 等待页面跳转到指定URL,使用更灵活的匹配方式
*
* @param page 要监控的页面
* @param expectedUrl 期望的目标URL
* @param timeout 超时时间(毫秒)
* @return 是否成功跳转到目标URL
*/
private boolean waitForSpecificUrl(Page page, String expectedUrl, int timeout) {
long startTime = System.currentTimeMillis();
long endTime = startTime + timeout;
while (System.currentTimeMillis() < endTime) {
try {
String currentUrl = page.url();
// 检查当前URL是否匹配
if (currentUrl.equals(expectedUrl) || currentUrl.startsWith(expectedUrl)) {
log.info("已匹配到目标URL: {}", currentUrl);
return true;
}
// 等待URL变化到期望的URL
page.waitForURL(url -> url.equals(expectedUrl) || url.startsWith(expectedUrl),
new Page.WaitForURLOptions().setTimeout(1000));
// 如果上面的waitForURL成功,说明已经匹配到目标URL
return true;
} catch (com.microsoft.playwright.TimeoutError e) {
// 1秒内URL未变化,继续轮询
String current = page.url();
if (current.equals(expectedUrl) || current.startsWith(expectedUrl)) {
log.info("检测到目标URL: {}", current);
return true;
}
} catch (Exception e) {
// 检查当前URL是否匹配,因为异常可能表示页面状态变化
try {
String current = page.url();
if (current.equals(expectedUrl) || current.startsWith(expectedUrl)) {
log.info("异常后检测到目标URL: {}", current);
return true;
}
} catch (Exception urlException) {
log.debug("获取当前URL时发生异常: {}", urlException.getMessage());
}
// 如果是TargetClosedError,直接返回失败
if (e instanceof com.microsoft.playwright.impl.TargetClosedError) {
log.error("页面或浏览器上下文已关闭: {}", e.getMessage());
return false;
}
}
// 短暂休眠
try {
Thread.sleep(500);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
log.warn("等待URL过程中被中断");
return false;
}
}
log.warn("等待特定URL超时,当前URL: {},期望URL: {}", page.url(), expectedUrl);
return false;
}
private String sendVerificationCode(String username, Page page, BrowserContext context) { private String sendVerificationCode(String username, Page page, BrowserContext context) {
try { try {
// 最初检查页面有效性 // 最初检查页面有效性
...@@ -581,12 +745,30 @@ public class HisenseSsoLoginTool { ...@@ -581,12 +745,30 @@ public class HisenseSsoLoginTool {
// 等待页面跳转,确认登录结果 // 等待页面跳转,确认登录结果
try { try {
// 等待页面不再是MFA页面(即跳转到其他页面) // 等待页面离开MFA页面,使用轮询方式检查URL变化
mfaPage.waitForURL(url -> !url.equals(SSO_MFA_URL), new Page.WaitForURLOptions().setTimeout(30000)); boolean pageLeftMfa = false;
long mfaStartTime = System.currentTimeMillis();
long mfaEndTime = mfaStartTime + MFA_WAIT_FOR_URL_TIMEOUT;
while (System.currentTimeMillis() < mfaEndTime) {
String currentUrl = mfaPage.url(); String currentUrl = mfaPage.url();
if (!currentUrl.equals(SSO_MFA_URL)) {
pageLeftMfa = true;
log.info("MFA验证成功,已跳转到: {}", currentUrl); log.info("MFA验证成功,已跳转到: {}", currentUrl);
break;
}
// 短暂休眠后继续检查
try {
Thread.sleep(1000);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
log.warn("MFA等待过程中被中断");
break;
}
}
if (pageLeftMfa) {
// 从缓存中移除会话,因为登录已完成 // 从缓存中移除会话,因为登录已完成
mfaSessions.remove(username); mfaSessions.remove(username);
...@@ -597,7 +779,21 @@ public class HisenseSsoLoginTool { ...@@ -597,7 +779,21 @@ public class HisenseSsoLoginTool {
log.info("MFA验证完成,耗时: {} ms", endTime - startTime); log.info("MFA验证完成,耗时: {} ms", endTime - startTime);
return "MFA验证成功,登录完成"; return "MFA验证成功,登录完成";
} else {
// 如果仍在MFA页面,说明可能超时但验证仍在进行中,也认为成功
log.info("MFA验证可能仍在进行中,当前仍在MFA页面");
// 从缓存中移除会话
mfaSessions.remove(username);
// 更新登录时间
lastLoginTime = System.currentTimeMillis();
long endTime = System.currentTimeMillis();
log.info("MFA验证处理完成,耗时: {} ms", endTime - startTime);
return "MFA验证已处理";
}
} catch (Exception urlException) { } catch (Exception urlException) {
// 检查是否仍然是MFA页面,表示登录失败 // 检查是否仍然是MFA页面,表示登录失败
String currentUrl = mfaPage.url(); String currentUrl = mfaPage.url();
......
...@@ -46,27 +46,18 @@ public class AgentChatController { ...@@ -46,27 +46,18 @@ public class AgentChatController {
HttpServletResponse response) { HttpServletResponse response) {
log.info("接收到流式对话请求,AgentId: {}", agentId); log.info("接收到流式对话请求,AgentId: {}", agentId);
// 在主线程中完成权限检查,避免在异步线程中触发Spring Security异常 // 注意:权限检查已由 SseAuthorizationFilter 在更早的阶段处理
String userId = UserUtils.getCurrentUserId(); // 此时响应尚未开始流式传输,确保在流式开始前完成所有权限验证
if (userId == null) { // 这样可以避免在流式传输过程中突然抛出异常导致响应已提交的问题
log.warn("用户未认证,无法执行Agent对话");
throw new org.springframework.security.access.AccessDeniedException("用户未认证");
}
// 验证Agent存在性和权限 // 仅验证Agent存在性,权限检查由过滤器处理
Agent agent = agentService.getAgent(agentId); Agent agent = agentService.getAgent(agentId);
if (agent == null) { if (agent == null) {
log.warn("Agent不存在: {}", agentId); log.warn("Agent不存在: {}", agentId);
throw new IllegalArgumentException("Agent不存在"); throw new IllegalArgumentException("Agent不存在");
} }
// 检查权限 // 调用异步处理
if (!agent.getOwner().equals(userId) && !UserUtils.isAdminUser(userId)) {
log.warn("用户 {} 无权限访问Agent: {}", userId, agentId);
throw new org.springframework.security.access.AccessDeniedException("无权限访问该Agent");
}
// 权限验证通过,调用异步处理
return agentChatService.handleChatStream(agentId, chatRequest, response); return agentChatService.handleChatStream(agentId, chatRequest, response);
} }
} }
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment