From 0d155ec5b259f717c9ea128eea52de93641a1a92 Mon Sep 17 00:00:00 2001 From: Timi Date: Wed, 16 Sep 2026 15:15:36 +0800 Subject: [PATCH] update AIAgent --- .../gao/ai/config/GaoAiProperties.java | 16 - .../ai/controller/GaoAiChatController.java | 56 +++- .../gao/ai/mcp/GaoAiMcpAuthentication.java | 39 +-- .../gao/ai/mcp/GaoAiMcpController.java | 18 +- .../modules/gao/ai/mcp/tool/GaoAiTool.java | 19 +- .../gao/ai/service/GaoAiChatService.java | 20 ++ .../implement/GaoAiChatServiceImplement.java | 299 +++++++++++++++++- .../ai/vo/GaoAiConversationListRequest.java | 22 ++ .../ai/vo/GaoAiConversationReadRequest.java | 22 ++ .../modules/gao/bean/GaoSystemSetting.java | 2 + 10 files changed, 441 insertions(+), 72 deletions(-) create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiConversationListRequest.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiConversationReadRequest.java diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/config/GaoAiProperties.java b/src/main/java/com/imyeyu/api/modules/gao/ai/config/GaoAiProperties.java index c7c4882..d184eca 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/ai/config/GaoAiProperties.java +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/config/GaoAiProperties.java @@ -14,10 +14,6 @@ import org.springframework.stereotype.Component; @Component public class GaoAiProperties { - /// MCP 服务间密钥 - @Value("${gao.ai.mcp-service-key:}") - private String mcpServiceKey; - /// AI 会话有效期,单位秒 @Value("${gao.ai.session-ttl-seconds:300}") private long sessionTtlSeconds; @@ -33,16 +29,4 @@ public class GaoAiProperties { /// Dify 流式读取空闲超时,单位秒 @Value("${gao.ai.read-timeout-seconds:600}") private long readTimeoutSeconds; - - /// true 为允许缺少 AI 会话时使用固定测试会话,仅用于本地调试 - @Value("${gao.ai.allow-missing-session:false}") - private boolean allowMissingSession; - - /// 缺少 AI 会话时使用的测试用户 ID - @Value("${gao.ai.debug-user-id:dify-agent-debug}") - private String debugUserId; - - /// 缺少 AI 会话时使用的测试门店 ID - @Value("${gao.ai.debug-store-id:00000000-0000-0000-0000-000000000001}") - private String debugStoreId; } diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/controller/GaoAiChatController.java b/src/main/java/com/imyeyu/api/modules/gao/ai/controller/GaoAiChatController.java index 439e858..5687bd1 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/ai/controller/GaoAiChatController.java +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/controller/GaoAiChatController.java @@ -1,8 +1,11 @@ package com.imyeyu.api.modules.gao.ai.controller; +import com.fasterxml.jackson.databind.JsonNode; import com.imyeyu.api.modules.gao.ai.service.GaoAiAccessException; import com.imyeyu.api.modules.gao.ai.service.GaoAiChatService; import com.imyeyu.api.modules.gao.ai.vo.GaoAiChatRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationListRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationReadRequest; import com.imyeyu.spring.annotation.IgnoreGlobalReturn; import com.imyeyu.spring.annotation.RequestRateLimit; import lombok.RequiredArgsConstructor; @@ -10,6 +13,7 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -38,7 +42,57 @@ public class GaoAiChatController { try { return chatService.chat(request); } catch (GaoAiAccessException e) { - throw new ResponseStatusException(HttpStatus.valueOf(e.getStatus()), e.getMessage(), e); + throw accessException(e); } } + + /// 列出当前登录用户的历史会话 + /// + /// @param request 历史会话分页请求 + /// @return 历史会话分页结果 + @RequestRateLimit + @PostMapping("/conversation/list") + public JsonNode listConversation(@RequestBody(required = false) GaoAiConversationListRequest request) { + try { + return chatService.listConversation(request); + } catch (GaoAiAccessException e) { + throw accessException(e); + } + } + + /// 读取指定历史会话的消息 + /// + /// @param request 历史会话读取请求 + /// @return 会话消息分页结果 + @RequestRateLimit + @PostMapping("/conversation/read") + public JsonNode readConversation(@RequestBody GaoAiConversationReadRequest request) { + try { + return chatService.readConversation(request); + } catch (GaoAiAccessException e) { + throw accessException(e); + } + } + + /// 删除当前登录用户的历史会话 + /// + /// @param conversationId Dify 会话 ID + /// @return 无 + @RequestRateLimit + @PostMapping("/conversation/delete") + public void deleteConversation(@RequestParam String conversationId) { + try { + chatService.deleteConversation(conversationId); + } catch (GaoAiAccessException e) { + throw accessException(e); + } + } + + /// 转换智能体访问异常 + /// + /// @param exception 智能体访问异常 + /// @return HTTP 响应状态异常 + private ResponseStatusException accessException(GaoAiAccessException exception) { + return new ResponseStatusException(HttpStatus.valueOf(exception.getStatus()), exception.getMessage(), exception); + } } diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpAuthentication.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpAuthentication.java index 1c2e769..be3fd04 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpAuthentication.java +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpAuthentication.java @@ -1,9 +1,11 @@ package com.imyeyu.api.modules.gao.ai.mcp; +import com.imyeyu.api.modules.common.service.SettingService; import com.imyeyu.api.modules.gao.ai.config.GaoAiProperties; import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; import com.imyeyu.api.modules.gao.ai.service.GaoAiSessionService; import com.imyeyu.api.modules.gao.ai.service.implement.GaoAiSessionServiceImplement.GaoAiSessionException; +import com.imyeyu.api.modules.gao.bean.GaoSystemSetting; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -21,22 +23,21 @@ import java.security.MessageDigest; public class GaoAiMcpAuthentication { private final GaoAiProperties properties; + private final SettingService settingService; private final GaoAiSessionService sessionService; /// 校验 Dify 服务密钥和 AI 会话 /// /// @param authorization Authorization 请求头 - /// @param sessionId X-Timi-AI-Session 请求头 + /// @param headerSessionId X-Timi-AI-Session 请求头 + /// @param argumentSessionId MCP 工具参数中的 AI 会话 ID /// @param toolName 工具名称,可为空 /// @return 已认证会话 - public GaoAiSession authenticate(String authorization, String sessionId, String toolName) { + public GaoAiSession authenticate(String authorization, String headerSessionId, String argumentSessionId, String toolName) { authenticateService(authorization); - if (isBlank(sessionId) && properties.isAllowMissingSession()) { - GaoAiSession session = debugSession(); - if (toolName != null && !session.getAllowedTools().contains(toolName)) { - throw new GaoAiSessionException("AI 会话无权调用该工具", 403); - } - return session; + String sessionId = headerSessionId; + if (isBlank(sessionId) && !isBlank(argumentSessionId)) { + sessionId = argumentSessionId; } GaoAiSession session = sessionService.require(sessionId); if (toolName != null && (session.getAllowedTools() == null || !session.getAllowedTools().contains(toolName))) { @@ -51,9 +52,9 @@ public class GaoAiMcpAuthentication { /// /// @param authorization Authorization 请求头 public void authenticateService(String authorization) { - String expectedKey = trim(properties.getMcpServiceKey()); + String key = settingService.getSystem(GaoSystemSetting.DIFY_MCP_SERVICE_KEY).asString(); String actualKey = bearerValue(authorization); - if (expectedKey.isEmpty() || actualKey.isEmpty() || !constantTimeEquals(expectedKey, actualKey)) { + if (key.isEmpty() || actualKey.isEmpty() || !constantTimeEquals(key, actualKey)) { throw new GaoAiSessionException("MCP 服务认证失败", 401); } } @@ -77,24 +78,6 @@ public class GaoAiMcpAuthentication { return value == null ? "" : value.trim(); } - /// 创建固定的开发测试会话 - /// - /// @return 开发测试会话 - private GaoAiSession debugSession() { - String userId = trim(properties.getDebugUserId()); - String storeId = trim(properties.getDebugStoreId()); - if (userId.isEmpty() || storeId.isEmpty()) { - throw new GaoAiSessionException("开发测试会话缺少用户或门店配置", 401); - } - GaoAiSession session = new GaoAiSession(); - session.setSessionId("dify-agent-debug-bypass"); - session.setUserId(userId); - session.setStoreId(storeId); - session.setAllowedTools(GaoAiToolNames.orderedReadOnlyTools()); - session.setExpiresAt(Long.MAX_VALUE); - return session; - } - /// 判断字符串是否为空 /// /// @param value 待判断字符串 diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpController.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpController.java index 0d1b58e..e68a98f 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpController.java +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpController.java @@ -2,6 +2,7 @@ package com.imyeyu.api.modules.gao.ai.mcp; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.imyeyu.api.modules.gao.ai.entity.GaoAiAuditLog; import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; import com.imyeyu.api.modules.gao.ai.mcp.tool.GaoAiTool; @@ -90,12 +91,13 @@ public class GaoAiMcpController { yield response(200, success(id, toolsResult())); } case "tools/call" -> { - GaoAiSession session = authentication.authenticate(authorization, aiSessionId, text(request.path("params"), "name")); - if (!hasText(aiSessionId)) { - log.warn("GAO MCP 使用开发测试会话:toolName={}, userId={}, storeId={}", - toolName, session.getUserId(), session.getStoreId()); + JsonNode params = request.path("params"); + String argumentSessionId = text(params.path("arguments"), "aiSessionId"); + GaoAiSession session = authentication.authenticate(authorization, aiSessionId, argumentSessionId, text(params, "name")); + if (hasText(argumentSessionId) && !hasText(aiSessionId)) { + log.info("GAO MCP 使用工具参数中的 AI 会话:toolName={}, aiSessionLength={}", toolName, length(argumentSessionId)); } - yield response(200, success(id, executeTool(session, request.path("params"), aiSessionId))); + yield response(200, success(id, executeTool(session, params, hasText(aiSessionId) ? aiSessionId : argumentSessionId))); } default -> response(400, error(id, -32601, "MCP 方法不支持")); }; @@ -126,12 +128,14 @@ public class GaoAiMcpController { if (arguments == null || !arguments.isObject()) { throw new GaoAiToolException("工具参数格式无效", 400); } - if (containsField(arguments, "storeId")) { + ObjectNode businessArguments = ((ObjectNode) arguments).deepCopy(); + businessArguments.remove("aiSessionId"); + if (containsField(businessArguments, "storeId")) { throw new GaoAiToolException("工具请求不允许携带 storeId", 403); } GaoAiToolRequest request; try { - request = jackson.treeToValue(arguments, GaoAiToolRequest.class); + request = jackson.treeToValue(businessArguments, GaoAiToolRequest.class); } catch (Exception e) { throw new GaoAiToolException("工具参数格式无效", 400); } diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/GaoAiTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/GaoAiTool.java index ad23182..7da77dd 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/GaoAiTool.java +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/GaoAiTool.java @@ -4,6 +4,7 @@ import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import java.util.LinkedHashMap; import java.util.Map; /// GAO 智能体只读工具 @@ -36,15 +37,19 @@ public interface GaoAiTool { ), "additionalProperties", false ); + Map properties = new LinkedHashMap<>(); + properties.put("query", Map.of("type", "string", "description", "自然语言关键词")); + properties.put("timeRange", timeRange); + properties.put("filters", Map.of("type", "object")); + properties.put("pageSize", Map.of("type", "integer", "minimum", 1, "maximum", 20)); + properties.put("cursor", Map.of("type", "string")); + properties.put("aiSessionId", Map.of( + "type", "string", + "description", "由工作流变量注入的 AI 会话 ID,不允许由模型生成" + )); return Map.of( "type", "object", - "properties", Map.of( - "query", Map.of("type", "string", "description", "自然语言关键词"), - "timeRange", timeRange, - "filters", Map.of("type", "object"), - "pageSize", Map.of("type", "integer", "minimum", 1, "maximum", 20), - "cursor", Map.of("type", "string") - ), + "properties", properties, "additionalProperties", false ); } diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiChatService.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiChatService.java index 779cf05..94a0a20 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiChatService.java +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiChatService.java @@ -1,6 +1,9 @@ package com.imyeyu.api.modules.gao.ai.service; +import com.fasterxml.jackson.databind.JsonNode; import com.imyeyu.api.modules.gao.ai.vo.GaoAiChatRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationListRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationReadRequest; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; /// GAO 智能体聊天服务 @@ -14,4 +17,21 @@ public interface GaoAiChatService { /// @param request 聊天请求 /// @return SSE 发射器 SseEmitter chat(GaoAiChatRequest request); + + /// 列出当前登录用户的历史会话 + /// + /// @param request 历史会话分页请求 + /// @return 历史会话分页结果 + JsonNode listConversation(GaoAiConversationListRequest request); + + /// 读取当前登录用户的历史会话消息 + /// + /// @param request 历史会话读取请求 + /// @return 会话消息分页结果 + JsonNode readConversation(GaoAiConversationReadRequest request); + + /// 删除当前登录用户的历史会话 + /// + /// @param conversationId Dify 会话 ID + void deleteConversation(String conversationId); } diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiChatServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiChatServiceImplement.java index 464c854..7c6eeb6 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiChatServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiChatServiceImplement.java @@ -2,6 +2,7 @@ package com.imyeyu.api.modules.gao.ai.service.implement; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.imyeyu.api.bean.ModuleCode; import com.imyeyu.api.modules.common.service.SettingService; @@ -13,6 +14,8 @@ import com.imyeyu.api.modules.gao.ai.service.GaoAiAuditService; import com.imyeyu.api.modules.gao.ai.service.GaoAiChatService; import com.imyeyu.api.modules.gao.ai.service.GaoAiSessionService; import com.imyeyu.api.modules.gao.ai.vo.GaoAiChatRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationListRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationReadRequest; import com.imyeyu.api.modules.gao.bean.GaoRoleCode; import com.imyeyu.api.modules.gao.bean.GaoSystemSetting; import com.imyeyu.api.modules.gao.entity.GaoStore; @@ -33,6 +36,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.URI; +import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -101,13 +105,7 @@ public class GaoAiChatServiceImplement implements GaoAiChatService { if (request.getConversationId() != null && 128 < request.getConversationId().length()) { throw new GaoAiAccessException("会话 ID 无效", 400); } - User user = userLoginService.getLoginUser(); - if (user == null) { - throw new GaoAiAccessException("登录已过期,请重新登录", 401); - } - if (!roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.STORE_MANAGER.name())) { - throw new GaoAiAccessException("当前账号无权使用门店智能助手", 403); - } + User user = requireAiUser(); String storeId; try { storeId = storeScopeService.resolveStoreId(request.getStoreId()); @@ -122,6 +120,50 @@ public class GaoAiChatServiceImplement implements GaoAiChatService { return stream(session, message); } + @Override + public JsonNode listConversation(GaoAiConversationListRequest request) { + User user = requireAiUser(); + String lastId = optionalValue(request == null ? null : request.getLastId(), "上一页游标", 128); + int limit = normalizeLimit(request == null ? null : request.getLimit()); + String sortBy = normalizeSortBy(request == null ? null : request.getSortBy()); + StringBuilder url = new StringBuilder("/conversations"); + appendQueryParameter(url, "user", difyUser(user)); + appendQueryParameter(url, "last_id", lastId); + appendQueryParameter(url, "limit", String.valueOf(limit)); + appendQueryParameter(url, "sort_by", sortBy); + return sanitizeConversationList(parseJson(sendDify(newDifyRequest(url.toString()).GET().build(), "查询历史会话"))); + } + + @Override + public JsonNode readConversation(GaoAiConversationReadRequest request) { + User user = requireAiUser(); + if (request == null) { + throw new GaoAiAccessException("会话读取参数不能为空", 400); + } + String conversationId = requiredValue(request.getConversationId(), "会话 ID", 128); + String firstId = optionalValue(request.getFirstId(), "上一页游标", 128); + int limit = normalizeLimit(request.getLimit()); + StringBuilder url = new StringBuilder("/messages"); + appendQueryParameter(url, "user", difyUser(user)); + appendQueryParameter(url, "conversation_id", conversationId); + appendQueryParameter(url, "first_id", firstId); + appendQueryParameter(url, "limit", String.valueOf(limit)); + return sanitizeConversationMessages(parseJson(sendDify(newDifyRequest(url.toString()).GET().build(), "读取历史会话"))); + } + + @Override + public void deleteConversation(String conversationId) { + User user = requireAiUser(); + String value = requiredValue(conversationId, "会话 ID", 128); + ObjectNode body = jackson.createObjectNode(); + body.put("user", difyUser(user)); + HttpRequest request = newDifyRequest("/conversations/" + encodePathSegment(value)) + .method("DELETE", HttpRequest.BodyPublishers.ofString(writeJson(body), StandardCharsets.UTF_8)) + .header("Content-Type", "application/json") + .build(); + sendDify(request, "删除历史会话"); + } + private SseEmitter stream(GaoAiSession session, String message) { long readTimeoutSeconds = positive(properties.getReadTimeoutSeconds(), 600L); long emitterTimeout = Math.min(Long.MAX_VALUE - 1L, readTimeoutSeconds * 1000L + 30_000L); @@ -190,19 +232,250 @@ public class GaoAiChatServiceImplement implements GaoAiChatService { GaoStore store = storeService.get(session.getStoreId()); inputs.put("storeName", store == null ? "当前门店" : store.getName()); inputs.put("today", LocalDate.now(ZONE).toString()); - String url = settingService.getSystem(DifySetting.BASE_URL).asString(); - String appApiKey = settingService.getSystem(GaoSystemSetting.DIFY_APP_API_KEY).asString(); - return HttpRequest.newBuilder() - .uri(URI.create(url + "/chat-messages")) + return newDifyRequest("/chat-messages") .timeout(Duration.ofSeconds(readTimeoutSeconds)) - .header("Authorization", "Bearer " + appApiKey) .header("X-Timi-AI-Session", session.getSessionId()) .header("Content-Type", "application/json") - .header("Accept", "text/event-stream") + .setHeader("Accept", "text/event-stream") .POST(HttpRequest.BodyPublishers.ofString(writeJson(body), StandardCharsets.UTF_8)) .build(); } + /// 获取当前登录且有权使用智能体的用户 + /// + /// @return 当前登录用户 + private User requireAiUser() { + User user = userLoginService.getLoginUser(); + if (user == null) { + throw new GaoAiAccessException("登录已过期,请重新登录", 401); + } + if (!roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.STORE_MANAGER.name())) { + throw new GaoAiAccessException("当前账号无权使用门店智能助手", 403); + } + return user; + } + + /// 创建 Dify API 请求 + /// + /// @param path Dify API 路径,可包含已编码的查询参数 + /// @return Dify API 请求构造器 + private HttpRequest.Builder newDifyRequest(String path) { + String appApiKey = settingService.getSystem(GaoSystemSetting.DIFY_APP_API_KEY).asString(); + if (isBlank(appApiKey)) { + throw new GaoAiAccessException("智能体服务未配置", 503); + } + return HttpRequest.newBuilder(difyUri(path)) + .timeout(Duration.ofSeconds(Math.min(60L, positive(properties.getReadTimeoutSeconds(), 600L)))) + .header("Authorization", "Bearer " + appApiKey) + .header("Accept", "application/json"); + } + + /// 调用 Dify API + /// + /// @param request Dify API 请求 + /// @param operation 操作名称 + /// @return Dify 响应正文 + private String sendDify(HttpRequest request, String operation) { + HttpResponse response; + try { + response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new GaoAiAccessException("智能体服务请求被中断", 503); + } catch (IOException | RuntimeException e) { + log.warn("{}失败", operation, e); + throw new GaoAiAccessException("智能体服务暂时不可用,请稍后重试", 503); + } + if (response.statusCode() < 200 || 299 < response.statusCode()) { + log.warn("{}失败:statusCode={}", operation, response.statusCode()); + int status = response.statusCode() == 404 ? 404 : 503; + String message = status == 404 ? "历史会话不存在" : "智能体服务暂时不可用,请稍后重试"; + throw new GaoAiAccessException(message, status); + } + return response.body(); + } + + /// 解析 Dify JSON 响应 + /// + /// @param responseBody 响应正文 + /// @return JSON 节点 + private JsonNode parseJson(String responseBody) { + try { + return jackson.readTree(responseBody); + } catch (IOException | RuntimeException e) { + log.warn("解析智能体历史会话响应失败", e); + throw new GaoAiAccessException("智能体服务返回数据无效", 503); + } + } + + /// 过滤会话列表中的 Dify 内部字段 + /// + /// @param response Dify 响应 + /// @return 脱敏后的响应 + private JsonNode sanitizeConversationList(JsonNode response) { + if (response == null || !response.isObject()) { + return response; + } + ObjectNode result = jackson.createObjectNode(); + copyFields(response, result, "has_more", "limit"); + ArrayNode data = result.putArray("data"); + for (JsonNode item : response.path("data")) { + if (!item.isObject()) { + continue; + } + ObjectNode conversation = data.addObject(); + copyFields(item, conversation, "id", "name", "status", "introduction", "created_at", "updated_at"); + } + return result; + } + + /// 过滤会话消息中的 Dify 内部字段 + /// + /// @param response Dify 响应 + /// @return 脱敏后的响应 + private JsonNode sanitizeConversationMessages(JsonNode response) { + if (response == null || !response.isObject()) { + return response; + } + ObjectNode result = jackson.createObjectNode(); + copyFields(response, result, "has_more", "limit"); + ArrayNode data = result.putArray("data"); + for (JsonNode item : response.path("data")) { + if (!item.isObject()) { + continue; + } + ObjectNode message = data.addObject(); + copyFields(item, message, "id", "conversation_id", "query", "answer", "created_at", "status", "feedback"); + } + return result; + } + + /// 复制指定的 JSON 字段 + /// + /// @param source 源 JSON 对象 + /// @param target 目标 JSON 对象 + /// @param fieldNames 字段名称 + /// @return 无 + private void copyFields(JsonNode source, ObjectNode target, String... fieldNames) { + for (String fieldName : fieldNames) { + if (source.has(fieldName)) { + target.set(fieldName, source.get(fieldName)); + } + } + } + + /// 生成 Dify 用户标识 + /// + /// @param user 登录用户 + /// @return Dify 用户标识 + private String difyUser(User user) { + return "gao-store-manager:" + user.getId(); + } + + /// 添加 URL 查询参数 + /// + /// @param url URL 构造器 + /// @param name 参数名称 + /// @param value 参数值 + /// @return 无 + private void appendQueryParameter(StringBuilder url, String name, String value) { + if (isBlank(value)) { + return; + } + url.append(url.indexOf("?") < 0 ? '?' : '&') + .append(name) + .append('=') + .append(URLEncoder.encode(value, StandardCharsets.UTF_8)); + } + + /// 生成 Dify API URI + /// + /// @param path Dify API 路径 + /// @return Dify API URI + private URI difyUri(String path) { + String baseUrl = settingService.getSystem(DifySetting.BASE_URL).asString(); + if (isBlank(baseUrl)) { + throw new GaoAiAccessException("智能体服务未配置", 503); + } + String normalizedBaseUrl = baseUrl.trim(); + while (normalizedBaseUrl.endsWith("/")) { + normalizedBaseUrl = normalizedBaseUrl.substring(0, normalizedBaseUrl.length() - 1); + } + try { + URI uri = URI.create(normalizedBaseUrl + (path.startsWith("/") ? path : "/" + path)); + if (!uri.isAbsolute() || isBlank(uri.getScheme()) || isBlank(uri.getHost())) { + throw new IllegalArgumentException("Dify 地址必须是绝对 HTTP 地址"); + } + return uri; + } catch (IllegalArgumentException e) { + throw new GaoAiAccessException("智能体服务配置无效", 503); + } + } + + /// 编码 URL 路径片段 + /// + /// @param value 路径片段 + /// @return 编码后的路径片段 + private String encodePathSegment(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + /// 标准化分页条数 + /// + /// @param limit 请求的分页条数 + /// @return 标准化后的分页条数 + private int normalizeLimit(Integer limit) { + int value = limit == null ? 20 : limit; + if (!(0 < value) || 100 < value) { + throw new GaoAiAccessException("分页条数必须在 1 至 100 之间", 400); + } + return value; + } + + /// 标准化会话排序方式 + /// + /// @param sortBy 请求的排序方式 + /// @return 标准化后的排序方式 + private String normalizeSortBy(String sortBy) { + String value = isBlank(sortBy) ? "-updated_at" : sortBy.trim(); + if (!"created_at".equals(value) && !"-created_at".equals(value) + && !"updated_at".equals(value) && !"-updated_at".equals(value)) { + throw new GaoAiAccessException("会话排序方式无效", 400); + } + return value; + } + + /// 校验必填文本参数 + /// + /// @param value 参数值 + /// @param name 参数名称 + /// @param maxLength 最大长度 + /// @return 去除首尾空白后的参数值 + private String requiredValue(String value, String name, int maxLength) { + String result = optionalValue(value, name, maxLength); + if (result == null) { + throw new GaoAiAccessException(name + "不能为空", 400); + } + return result; + } + + /// 校验可选文本参数 + /// + /// @param value 参数值 + /// @param name 参数名称 + /// @param maxLength 最大长度 + /// @return 去除首尾空白后的参数值,无值时返回 null + private String optionalValue(String value, String name, int maxLength) { + if (isBlank(value)) { + return null; + } + String result = value.trim(); + if (maxLength < result.length()) { + throw new GaoAiAccessException(name + "无效", 400); + } + return result; + } + private void readResponse(HttpResponse response, SseEmitter emitter, AtomicBoolean finished, AtomicReference>> upstreamRef, AtomicReference> timeoutRef, AtomicReference status, AtomicBoolean auditRecorded, GaoAiSession session, String message, long startedAt, diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiConversationListRequest.java b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiConversationListRequest.java new file mode 100644 index 0000000..40cdd1d --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiConversationListRequest.java @@ -0,0 +1,22 @@ +package com.imyeyu.api.modules.gao.ai.vo; + +import lombok.Data; + +/// GAO 智能体历史会话列表请求 +/// +/// 字段与 Dify 会话列表分页参数对应,但用户标识由后端注入 +/// +/// @author Codex +/// @since 2026-09-16 +@Data +public class GaoAiConversationListRequest { + + /// 上一页最后一条会话 ID,可为空 + private String lastId; + + /// 返回条数,可为空 + private Integer limit; + + /// 排序方式,可选 `created_at`、`-created_at`、`updated_at`、`-updated_at` + private String sortBy; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiConversationReadRequest.java b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiConversationReadRequest.java new file mode 100644 index 0000000..c1d6d43 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiConversationReadRequest.java @@ -0,0 +1,22 @@ +package com.imyeyu.api.modules.gao.ai.vo; + +import lombok.Data; + +/// GAO 智能体历史会话消息读取请求 +/// +/// 用户标识由后端注入,前端只能指定会话和分页游标 +/// +/// @author Codex +/// @since 2026-09-16 +@Data +public class GaoAiConversationReadRequest { + + /// Dify 会话 ID + private String conversationId; + + /// 上一页第一条消息 ID,可为空 + private String firstId; + + /// 返回条数,可为空 + private Integer limit; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/bean/GaoSystemSetting.java b/src/main/java/com/imyeyu/api/modules/gao/bean/GaoSystemSetting.java index cc58962..0a38e9c 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/bean/GaoSystemSetting.java +++ b/src/main/java/com/imyeyu/api/modules/gao/bean/GaoSystemSetting.java @@ -17,6 +17,8 @@ public enum GaoSystemSetting implements SettingEnum { DIFY_APP_API_KEY(null, Setting.ValueType.STRING), + DIFY_MCP_SERVICE_KEY(null, Setting.ValueType.STRING), + SERVICE_LICENSE(null, Setting.ValueType.STRING), PRIVACY_LICENSE(null, Setting.ValueType.STRING),