update AIAgent
This commit is contained in:
@@ -14,10 +14,6 @@ import org.springframework.stereotype.Component;
|
|||||||
@Component
|
@Component
|
||||||
public class GaoAiProperties {
|
public class GaoAiProperties {
|
||||||
|
|
||||||
/// MCP 服务间密钥
|
|
||||||
@Value("${gao.ai.mcp-service-key:}")
|
|
||||||
private String mcpServiceKey;
|
|
||||||
|
|
||||||
/// AI 会话有效期,单位秒
|
/// AI 会话有效期,单位秒
|
||||||
@Value("${gao.ai.session-ttl-seconds:300}")
|
@Value("${gao.ai.session-ttl-seconds:300}")
|
||||||
private long sessionTtlSeconds;
|
private long sessionTtlSeconds;
|
||||||
@@ -33,16 +29,4 @@ public class GaoAiProperties {
|
|||||||
/// Dify 流式读取空闲超时,单位秒
|
/// Dify 流式读取空闲超时,单位秒
|
||||||
@Value("${gao.ai.read-timeout-seconds:600}")
|
@Value("${gao.ai.read-timeout-seconds:600}")
|
||||||
private long readTimeoutSeconds;
|
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.imyeyu.api.modules.gao.ai.controller;
|
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.GaoAiAccessException;
|
||||||
import com.imyeyu.api.modules.gao.ai.service.GaoAiChatService;
|
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.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.IgnoreGlobalReturn;
|
||||||
import com.imyeyu.spring.annotation.RequestRateLimit;
|
import com.imyeyu.spring.annotation.RequestRateLimit;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
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.bind.annotation.RestController;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
@@ -38,7 +42,57 @@ public class GaoAiChatController {
|
|||||||
try {
|
try {
|
||||||
return chatService.chat(request);
|
return chatService.chat(request);
|
||||||
} catch (GaoAiAccessException e) {
|
} 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package com.imyeyu.api.modules.gao.ai.mcp;
|
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.config.GaoAiProperties;
|
||||||
import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession;
|
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.GaoAiSessionService;
|
||||||
import com.imyeyu.api.modules.gao.ai.service.implement.GaoAiSessionServiceImplement.GaoAiSessionException;
|
import com.imyeyu.api.modules.gao.ai.service.implement.GaoAiSessionServiceImplement.GaoAiSessionException;
|
||||||
|
import com.imyeyu.api.modules.gao.bean.GaoSystemSetting;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -21,22 +23,21 @@ import java.security.MessageDigest;
|
|||||||
public class GaoAiMcpAuthentication {
|
public class GaoAiMcpAuthentication {
|
||||||
|
|
||||||
private final GaoAiProperties properties;
|
private final GaoAiProperties properties;
|
||||||
|
private final SettingService settingService;
|
||||||
private final GaoAiSessionService sessionService;
|
private final GaoAiSessionService sessionService;
|
||||||
|
|
||||||
/// 校验 Dify 服务密钥和 AI 会话
|
/// 校验 Dify 服务密钥和 AI 会话
|
||||||
///
|
///
|
||||||
/// @param authorization Authorization 请求头
|
/// @param authorization Authorization 请求头
|
||||||
/// @param sessionId X-Timi-AI-Session 请求头
|
/// @param headerSessionId X-Timi-AI-Session 请求头
|
||||||
|
/// @param argumentSessionId MCP 工具参数中的 AI 会话 ID
|
||||||
/// @param toolName 工具名称,可为空
|
/// @param toolName 工具名称,可为空
|
||||||
/// @return 已认证会话
|
/// @return 已认证会话
|
||||||
public GaoAiSession authenticate(String authorization, String sessionId, String toolName) {
|
public GaoAiSession authenticate(String authorization, String headerSessionId, String argumentSessionId, String toolName) {
|
||||||
authenticateService(authorization);
|
authenticateService(authorization);
|
||||||
if (isBlank(sessionId) && properties.isAllowMissingSession()) {
|
String sessionId = headerSessionId;
|
||||||
GaoAiSession session = debugSession();
|
if (isBlank(sessionId) && !isBlank(argumentSessionId)) {
|
||||||
if (toolName != null && !session.getAllowedTools().contains(toolName)) {
|
sessionId = argumentSessionId;
|
||||||
throw new GaoAiSessionException("AI 会话无权调用该工具", 403);
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
GaoAiSession session = sessionService.require(sessionId);
|
GaoAiSession session = sessionService.require(sessionId);
|
||||||
if (toolName != null && (session.getAllowedTools() == null || !session.getAllowedTools().contains(toolName))) {
|
if (toolName != null && (session.getAllowedTools() == null || !session.getAllowedTools().contains(toolName))) {
|
||||||
@@ -51,9 +52,9 @@ public class GaoAiMcpAuthentication {
|
|||||||
///
|
///
|
||||||
/// @param authorization Authorization 请求头
|
/// @param authorization Authorization 请求头
|
||||||
public void authenticateService(String 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);
|
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);
|
throw new GaoAiSessionException("MCP 服务认证失败", 401);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,24 +78,6 @@ public class GaoAiMcpAuthentication {
|
|||||||
return value == null ? "" : value.trim();
|
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 待判断字符串
|
/// @param value 待判断字符串
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.imyeyu.api.modules.gao.ai.mcp;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
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.GaoAiAuditLog;
|
||||||
import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession;
|
import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession;
|
||||||
import com.imyeyu.api.modules.gao.ai.mcp.tool.GaoAiTool;
|
import com.imyeyu.api.modules.gao.ai.mcp.tool.GaoAiTool;
|
||||||
@@ -90,12 +91,13 @@ public class GaoAiMcpController {
|
|||||||
yield response(200, success(id, toolsResult()));
|
yield response(200, success(id, toolsResult()));
|
||||||
}
|
}
|
||||||
case "tools/call" -> {
|
case "tools/call" -> {
|
||||||
GaoAiSession session = authentication.authenticate(authorization, aiSessionId, text(request.path("params"), "name"));
|
JsonNode params = request.path("params");
|
||||||
if (!hasText(aiSessionId)) {
|
String argumentSessionId = text(params.path("arguments"), "aiSessionId");
|
||||||
log.warn("GAO MCP 使用开发测试会话:toolName={}, userId={}, storeId={}",
|
GaoAiSession session = authentication.authenticate(authorization, aiSessionId, argumentSessionId, text(params, "name"));
|
||||||
toolName, session.getUserId(), session.getStoreId());
|
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 方法不支持"));
|
default -> response(400, error(id, -32601, "MCP 方法不支持"));
|
||||||
};
|
};
|
||||||
@@ -126,12 +128,14 @@ public class GaoAiMcpController {
|
|||||||
if (arguments == null || !arguments.isObject()) {
|
if (arguments == null || !arguments.isObject()) {
|
||||||
throw new GaoAiToolException("工具参数格式无效", 400);
|
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);
|
throw new GaoAiToolException("工具请求不允许携带 storeId", 403);
|
||||||
}
|
}
|
||||||
GaoAiToolRequest request;
|
GaoAiToolRequest request;
|
||||||
try {
|
try {
|
||||||
request = jackson.treeToValue(arguments, GaoAiToolRequest.class);
|
request = jackson.treeToValue(businessArguments, GaoAiToolRequest.class);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new GaoAiToolException("工具参数格式无效", 400);
|
throw new GaoAiToolException("工具参数格式无效", 400);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.GaoAiToolRequest;
|
||||||
import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult;
|
import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/// GAO 智能体只读工具
|
/// GAO 智能体只读工具
|
||||||
@@ -36,15 +37,19 @@ public interface GaoAiTool {
|
|||||||
),
|
),
|
||||||
"additionalProperties", false
|
"additionalProperties", false
|
||||||
);
|
);
|
||||||
|
Map<String, Object> 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(
|
return Map.of(
|
||||||
"type", "object",
|
"type", "object",
|
||||||
"properties", Map.of(
|
"properties", properties,
|
||||||
"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")
|
|
||||||
),
|
|
||||||
"additionalProperties", false
|
"additionalProperties", false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.imyeyu.api.modules.gao.ai.service;
|
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.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;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
/// GAO 智能体聊天服务
|
/// GAO 智能体聊天服务
|
||||||
@@ -14,4 +17,21 @@ public interface GaoAiChatService {
|
|||||||
/// @param request 聊天请求
|
/// @param request 聊天请求
|
||||||
/// @return SSE 发射器
|
/// @return SSE 发射器
|
||||||
SseEmitter chat(GaoAiChatRequest request);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
+286
-13
@@ -2,6 +2,7 @@ package com.imyeyu.api.modules.gao.ai.service.implement;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import com.imyeyu.api.bean.ModuleCode;
|
import com.imyeyu.api.bean.ModuleCode;
|
||||||
import com.imyeyu.api.modules.common.service.SettingService;
|
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.GaoAiChatService;
|
||||||
import com.imyeyu.api.modules.gao.ai.service.GaoAiSessionService;
|
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.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.GaoRoleCode;
|
||||||
import com.imyeyu.api.modules.gao.bean.GaoSystemSetting;
|
import com.imyeyu.api.modules.gao.bean.GaoSystemSetting;
|
||||||
import com.imyeyu.api.modules.gao.entity.GaoStore;
|
import com.imyeyu.api.modules.gao.entity.GaoStore;
|
||||||
@@ -33,6 +36,7 @@ import java.io.IOException;
|
|||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
@@ -101,13 +105,7 @@ public class GaoAiChatServiceImplement implements GaoAiChatService {
|
|||||||
if (request.getConversationId() != null && 128 < request.getConversationId().length()) {
|
if (request.getConversationId() != null && 128 < request.getConversationId().length()) {
|
||||||
throw new GaoAiAccessException("会话 ID 无效", 400);
|
throw new GaoAiAccessException("会话 ID 无效", 400);
|
||||||
}
|
}
|
||||||
User user = userLoginService.getLoginUser();
|
User user = requireAiUser();
|
||||||
if (user == null) {
|
|
||||||
throw new GaoAiAccessException("登录已过期,请重新登录", 401);
|
|
||||||
}
|
|
||||||
if (!roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.STORE_MANAGER.name())) {
|
|
||||||
throw new GaoAiAccessException("当前账号无权使用门店智能助手", 403);
|
|
||||||
}
|
|
||||||
String storeId;
|
String storeId;
|
||||||
try {
|
try {
|
||||||
storeId = storeScopeService.resolveStoreId(request.getStoreId());
|
storeId = storeScopeService.resolveStoreId(request.getStoreId());
|
||||||
@@ -122,6 +120,50 @@ public class GaoAiChatServiceImplement implements GaoAiChatService {
|
|||||||
return stream(session, message);
|
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) {
|
private SseEmitter stream(GaoAiSession session, String message) {
|
||||||
long readTimeoutSeconds = positive(properties.getReadTimeoutSeconds(), 600L);
|
long readTimeoutSeconds = positive(properties.getReadTimeoutSeconds(), 600L);
|
||||||
long emitterTimeout = Math.min(Long.MAX_VALUE - 1L, readTimeoutSeconds * 1000L + 30_000L);
|
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());
|
GaoStore store = storeService.get(session.getStoreId());
|
||||||
inputs.put("storeName", store == null ? "当前门店" : store.getName());
|
inputs.put("storeName", store == null ? "当前门店" : store.getName());
|
||||||
inputs.put("today", LocalDate.now(ZONE).toString());
|
inputs.put("today", LocalDate.now(ZONE).toString());
|
||||||
String url = settingService.getSystem(DifySetting.BASE_URL).asString();
|
return newDifyRequest("/chat-messages")
|
||||||
String appApiKey = settingService.getSystem(GaoSystemSetting.DIFY_APP_API_KEY).asString();
|
|
||||||
return HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create(url + "/chat-messages"))
|
|
||||||
.timeout(Duration.ofSeconds(readTimeoutSeconds))
|
.timeout(Duration.ofSeconds(readTimeoutSeconds))
|
||||||
.header("Authorization", "Bearer " + appApiKey)
|
|
||||||
.header("X-Timi-AI-Session", session.getSessionId())
|
.header("X-Timi-AI-Session", session.getSessionId())
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Accept", "text/event-stream")
|
.setHeader("Accept", "text/event-stream")
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(writeJson(body), StandardCharsets.UTF_8))
|
.POST(HttpRequest.BodyPublishers.ofString(writeJson(body), StandardCharsets.UTF_8))
|
||||||
.build();
|
.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<String> 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<InputStream> response, SseEmitter emitter, AtomicBoolean finished,
|
private void readResponse(HttpResponse<InputStream> response, SseEmitter emitter, AtomicBoolean finished,
|
||||||
AtomicReference<CompletableFuture<HttpResponse<InputStream>>> upstreamRef, AtomicReference<ScheduledFuture<?>> timeoutRef,
|
AtomicReference<CompletableFuture<HttpResponse<InputStream>>> upstreamRef, AtomicReference<ScheduledFuture<?>> timeoutRef,
|
||||||
AtomicReference<String> status, AtomicBoolean auditRecorded, GaoAiSession session, String message, long startedAt,
|
AtomicReference<String> status, AtomicBoolean auditRecorded, GaoAiSession session, String message, long startedAt,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ public enum GaoSystemSetting implements SettingEnum {
|
|||||||
|
|
||||||
DIFY_APP_API_KEY(null, Setting.ValueType.STRING),
|
DIFY_APP_API_KEY(null, Setting.ValueType.STRING),
|
||||||
|
|
||||||
|
DIFY_MCP_SERVICE_KEY(null, Setting.ValueType.STRING),
|
||||||
|
|
||||||
SERVICE_LICENSE(null, Setting.ValueType.STRING),
|
SERVICE_LICENSE(null, Setting.ValueType.STRING),
|
||||||
|
|
||||||
PRIVACY_LICENSE(null, Setting.ValueType.STRING),
|
PRIVACY_LICENSE(null, Setting.ValueType.STRING),
|
||||||
|
|||||||
Reference in New Issue
Block a user