diff --git a/src/main/java/com/imyeyu/api/config/RedisConfig.java b/src/main/java/com/imyeyu/api/config/RedisConfig.java index c381e7b..120f9b2 100644 --- a/src/main/java/com/imyeyu/api/config/RedisConfig.java +++ b/src/main/java/com/imyeyu/api/config/RedisConfig.java @@ -2,6 +2,7 @@ package com.imyeyu.api.config; import com.fasterxml.jackson.databind.ObjectMapper; import com.imyeyu.api.modules.common.entity.Multilingual; +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; import com.imyeyu.api.modules.gao.bean.GaoUserInviteCache; import com.imyeyu.spring.bean.RedisConfigParams; import com.imyeyu.spring.config.AbstractRedisConfig; @@ -209,4 +210,10 @@ public class RedisConfig extends AbstractRedisConfig { public Redis getGaoUserInviteRedisTemplate() { return getRedis(database, RedisSerializers.STRING, RedisSerializers.jacksonSerializer(jackson, GaoUserInviteCache.class)); } + + /// @return AI 会话,AI 会话 ID: 会话授权内容 + @Bean("redisGaoAiSession") + public Redis getGaoAiSessionRedisTemplate() { + return getRedis(database, RedisSerializers.STRING, RedisSerializers.jacksonSerializer(jackson, GaoAiSession.class)); + } } 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 new file mode 100644 index 0000000..c7c4882 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/config/GaoAiProperties.java @@ -0,0 +1,48 @@ +package com.imyeyu.api.modules.gao.ai.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/// GAO 智能体配置 +/// +/// 密钥只从部署环境变量或外部配置读取,不允许写入仓库 +/// +/// @author Codex +/// @since 2026-09-15 +@Getter +@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; + + /// 工具单页最大条数 + @Value("${gao.ai.max-tool-page-size:20}") + private int maxToolPageSize; + + /// Dify 连接超时,单位秒 + @Value("${gao.ai.connect-timeout-seconds:10}") + private long connectTimeoutSeconds; + + /// 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 new file mode 100644 index 0000000..439e858 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/controller/GaoAiChatController.java @@ -0,0 +1,44 @@ +package com.imyeyu.api.modules.gao.ai.controller; + +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.spring.annotation.IgnoreGlobalReturn; +import com.imyeyu.spring.annotation.RequestRateLimit; +import lombok.RequiredArgsConstructor; +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.RestController; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/// GAO 智能体聊天接口 +/// +/// 浏览器只携带 Timi 登录令牌访问本接口,不接触 Dify 或 MCP 密钥 +/// +/// @author Codex +/// @since 2026-09-15 +@RestController +@RequiredArgsConstructor +@RequestMapping("/gao/ai") +public class GaoAiChatController { + + private final GaoAiChatService chatService; + + /// 发起门店智能体流式聊天 + /// + /// @param request 聊天请求 + /// @return SSE 发射器 + @RequestRateLimit + @IgnoreGlobalReturn + @PostMapping(value = "/chat", produces = "text/event-stream") + public SseEmitter chat(@RequestBody GaoAiChatRequest request) { + try { + return chatService.chat(request); + } catch (GaoAiAccessException e) { + throw new ResponseStatusException(HttpStatus.valueOf(e.getStatus()), e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/entity/GaoAiAuditLog.java b/src/main/java/com/imyeyu/api/modules/gao/ai/entity/GaoAiAuditLog.java new file mode 100644 index 0000000..b8abcb7 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/entity/GaoAiAuditLog.java @@ -0,0 +1,40 @@ +package com.imyeyu.api.modules.gao.ai.entity; + +import lombok.Data; + +/// GAO 智能体审计记录 +/// +/// 业务审计通过现有系统日志服务落库,本对象只承载脱敏后的结构化内容 +/// +/// @author Codex +/// @since 2026-09-15 +@Data +public class GaoAiAuditLog { + + /// 登录用户 ID + private String userId; + + /// 门店 ID + private String storeId; + + /// 问题摘要哈希,不保存问题原文 + private String questionDigest; + + /// Dify 会话 ID + private String conversationId; + + /// 调用的工具名称 + private String toolName; + + /// 查询耗时,单位毫秒 + private long duration; + + /// 查询状态 + private String status; + + /// 返回条数 + private long resultCount; + + /// 记录时间,单位毫秒 + private long createdAt; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/entity/GaoAiSession.java b/src/main/java/com/imyeyu/api/modules/gao/ai/entity/GaoAiSession.java new file mode 100644 index 0000000..0584000 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/entity/GaoAiSession.java @@ -0,0 +1,39 @@ +package com.imyeyu.api.modules.gao.ai.entity; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashSet; +import java.util.Set; + +/// GAO 智能体短期会话授权 +/// +/// 该对象只存储在服务端,`sessionId` 不是 JWT,也不是用户登录令牌 +/// +/// @author Codex +/// @since 2026-09-15 +@Data +@NoArgsConstructor +public class GaoAiSession { + + /// 不可预测的 AI 会话 ID + private String sessionId; + + /// 登录用户 ID + private String userId; + + /// 固定的门店 ID + private String storeId; + + /// Dify 会话 ID,可为空 + private String difyConversationId; + + /// 允许调用的只读工具 + private Set allowedTools = new LinkedHashSet<>(); + + /// 过期时间,单位毫秒 + private long expiresAt; + + /// 已调用工具次数 + private long usedCount; +} 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 new file mode 100644 index 0000000..1c2e769 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpAuthentication.java @@ -0,0 +1,105 @@ +package com.imyeyu.api.modules.gao.ai.mcp; + +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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/// Dify MCP 请求认证 +/// +/// 服务密钥只证明请求来自 Dify,门店和用户身份完全来自 AI 会话 +/// +/// @author Codex +/// @since 2026-09-15 +@Service +@RequiredArgsConstructor +public class GaoAiMcpAuthentication { + + private final GaoAiProperties properties; + private final GaoAiSessionService sessionService; + + /// 校验 Dify 服务密钥和 AI 会话 + /// + /// @param authorization Authorization 请求头 + /// @param sessionId X-Timi-AI-Session 请求头 + /// @param toolName 工具名称,可为空 + /// @return 已认证会话 + public GaoAiSession authenticate(String authorization, String sessionId, 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; + } + GaoAiSession session = sessionService.require(sessionId); + if (toolName != null && (session.getAllowedTools() == null || !session.getAllowedTools().contains(toolName))) { + throw new GaoAiSessionException("AI 会话无权调用该工具", 403); + } + return session; + } + + /// 只校验 Dify 服务间密钥 + /// + /// MCP 初始化和工具声明阶段尚未绑定店长会话,只能校验服务间身份 + /// + /// @param authorization Authorization 请求头 + public void authenticateService(String authorization) { + String expectedKey = trim(properties.getMcpServiceKey()); + String actualKey = bearerValue(authorization); + if (expectedKey.isEmpty() || actualKey.isEmpty() || !constantTimeEquals(expectedKey, actualKey)) { + throw new GaoAiSessionException("MCP 服务认证失败", 401); + } + } + + private String bearerValue(String authorization) { + if (authorization == null) { + return ""; + } + String value = authorization.trim(); + if (7 > value.length() || !value.regionMatches(true, 0, "Bearer ", 0, 7)) { + return ""; + } + return value.substring(7).trim(); + } + + private boolean constantTimeEquals(String left, String right) { + return MessageDigest.isEqual(left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8)); + } + + private String trim(String value) { + 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 待判断字符串 + /// @return true 为 null、空字符串或空白字符串 + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} 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 new file mode 100644 index 0000000..0d1b58e --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpController.java @@ -0,0 +1,269 @@ +package com.imyeyu.api.modules.gao.ai.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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; +import com.imyeyu.api.modules.gao.ai.service.GaoAiAuditService; +import com.imyeyu.api.modules.gao.ai.service.GaoAiSessionService; +import com.imyeyu.api.modules.gao.ai.service.GaoAiToolException; +import com.imyeyu.api.modules.gao.ai.service.implement.GaoAiSessionServiceImplement.GaoAiSessionException; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import com.imyeyu.spring.annotation.IgnoreGlobalReturn; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/// GAO 智能体 MCP Streamable HTTP 接口 +/// +/// Dify 通过 Docker 内网访问本接口,业务查询只允许调用已注册的只读工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Slf4j +@RestController +@RequestMapping("/mcp/gao-ai") +public class GaoAiMcpController { + + private final ObjectMapper jackson; + private final GaoAiMcpAuthentication authentication; + private final GaoAiSessionService sessionService; + private final GaoAiAuditService auditService; + private final Map toolMap; + + public GaoAiMcpController(ObjectMapper jackson, GaoAiMcpAuthentication authentication, + GaoAiSessionService sessionService, GaoAiAuditService auditService, List toolList) { + this.jackson = jackson; + this.authentication = authentication; + this.sessionService = sessionService; + this.auditService = auditService; + this.toolMap = toolList.stream().collect(Collectors.toUnmodifiableMap(GaoAiTool::name, Function.identity())); + } + + /// 处理 MCP JSON-RPC 请求 + /// + /// @param authorization Dify 服务密钥 + /// @param aiSessionId AI 会话头 + /// @param request JSON-RPC 请求 + /// @return JSON-RPC 响应 + @IgnoreGlobalReturn + @PostMapping + public ResponseEntity handle(@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization, + @RequestHeader(value = "X-Timi-AI-Session", required = false) String aiSessionId, + @RequestBody JsonNode request) { + JsonNode id = request == null ? null : request.get("id"); + String method = text(request, "method"); + String toolName = text(request == null ? null : request.path("params"), "name"); + log.info("GAO MCP 请求:method={}, toolName={}, authorizationPresent={}, aiSessionPresent={}, aiSessionLength={}", + method, toolName, hasText(authorization), hasText(aiSessionId), length(aiSessionId)); + try { + if (request == null || !request.isObject()) { + return response(400, error(id, -32600, "MCP 请求格式无效")); + } + if (method == null) { + return response(400, error(id, -32600, "MCP 请求缺少 method")); + } + return switch (method) { + case "initialize" -> { + authentication.authenticateService(authorization); + yield response(200, success(id, initializeResult())); + } + case "notifications/initialized", "ping" -> { + authentication.authenticateService(authorization); + yield response(200, success(id, Map.of())); + } + case "tools/list" -> { + authentication.authenticateService(authorization); + 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()); + } + yield response(200, success(id, executeTool(session, request.path("params"), aiSessionId))); + } + default -> response(400, error(id, -32601, "MCP 方法不支持")); + }; + } catch (GaoAiSessionException e) { + log.warn("GAO MCP 认证失败:method={}, toolName={}, status={}, authorizationPresent={}, aiSessionPresent={}, aiSessionLength={}, reason={}", + method, toolName, e.getStatus(), hasText(authorization), hasText(aiSessionId), length(aiSessionId), e.getMessage()); + return response(e.getStatus(), error(id, -32001, e.getMessage())); + } catch (GaoAiToolException e) { + log.warn("GAO MCP 工具调用失败:method={}, toolName={}, status={}, aiSessionPresent={}, reason={}", + method, toolName, e.getStatus(), hasText(aiSessionId), e.getMessage()); + return response(e.getStatus(), error(id, -32602, e.getMessage())); + } catch (RuntimeException e) { + log.warn("GAO 智能体 MCP 请求失败", e); + return response(500, error(id, -32603, "智能体查询失败,请稍后重试")); + } + } + + private Object executeTool(GaoAiSession session, JsonNode params, String aiSessionId) { + if (params == null || !params.isObject()) { + throw new GaoAiToolException("工具参数格式无效", 400); + } + String toolName = text(params, "name"); + GaoAiTool tool = toolMap.get(toolName); + if (tool == null) { + throw new GaoAiToolException("工具不存在或未启用", 403); + } + JsonNode arguments = params == null || !params.has("arguments") ? jackson.createObjectNode() : params.get("arguments"); + if (arguments == null || !arguments.isObject()) { + throw new GaoAiToolException("工具参数格式无效", 400); + } + if (containsField(arguments, "storeId")) { + throw new GaoAiToolException("工具请求不允许携带 storeId", 403); + } + GaoAiToolRequest request; + try { + request = jackson.treeToValue(arguments, GaoAiToolRequest.class); + } catch (Exception e) { + throw new GaoAiToolException("工具参数格式无效", 400); + } + long startedAt = System.currentTimeMillis(); + try { + GaoAiToolResult result = tool.execute(session, request); + long duration = System.currentTimeMillis() - startedAt; + if (hasText(aiSessionId)) { + sessionService.increaseUsedCount(aiSessionId); + } + recordAudit(session, request, toolName, duration, "SUCCESS", result == null ? 0L : result.getDetails().size()); + Map content = new LinkedHashMap<>(); + content.put("type", "text"); + content.put("text", writeJson(result)); + return Map.of("content", List.of(content), "structuredContent", result); + } catch (GaoAiToolException e) { + recordAudit(session, request, toolName, System.currentTimeMillis() - startedAt, "ERROR", 0L); + throw e; + } catch (RuntimeException e) { + recordAudit(session, request, toolName, System.currentTimeMillis() - startedAt, "ERROR", 0L); + throw e; + } + } + + private Map initializeResult() { + Map result = new LinkedHashMap<>(); + result.put("protocolVersion", "2025-03-26"); + result.put("capabilities", Map.of("tools", Map.of("listChanged", false))); + result.put("serverInfo", Map.of("name", "TimiServerAPI-GAO-AI", "version", "1.0")); + return result; + } + + private Map toolsResult() { + List> tools = toolMap.values().stream() + .map(tool -> { + Map item = new LinkedHashMap<>(); + item.put("name", tool.name()); + item.put("description", tool.description()); + item.put("inputSchema", tool.inputSchema()); + return item; + }).toList(); + return Map.of("tools", tools); + } + + private void recordAudit(GaoAiSession session, GaoAiToolRequest request, String toolName, long duration, + String status, long resultCount) { + GaoAiAuditLog audit = new GaoAiAuditLog(); + audit.setUserId(session.getUserId()); + audit.setStoreId(session.getStoreId()); + audit.setQuestionDigest(auditService.digest(request == null ? null : request.getQuery())); + audit.setConversationId(session.getDifyConversationId()); + audit.setToolName(toolName); + audit.setDuration(Math.max(0L, duration)); + audit.setStatus(status); + audit.setResultCount(resultCount); + audit.setCreatedAt(System.currentTimeMillis()); + auditService.record(audit); + } + + private ResponseEntity response(int status, Object body) { + return ResponseEntity.status(status).body(body); + } + + private Map success(JsonNode id, Object result) { + Map response = new LinkedHashMap<>(); + response.put("jsonrpc", "2.0"); + response.put("id", id); + response.put("result", result); + return response; + } + + private Map error(JsonNode id, int code, String message) { + Map response = new LinkedHashMap<>(); + response.put("jsonrpc", "2.0"); + response.put("id", id); + response.put("error", Map.of("code", code, "message", message)); + return response; + } + + private String text(JsonNode node, String field) { + if (node == null || !node.hasNonNull(field)) { + return null; + } + String value = node.get(field).asText(); + return value == null || value.trim().isEmpty() ? null : value.trim(); + } + + /// 判断字符串是否有实际内容 + /// + /// @param value 待判断字符串 + /// @return true 为有内容 + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + /// 获取字符串长度 + /// + /// @param value 待计算字符串 + /// @return 字符串长度 + private int length(String value) { + return value == null ? 0 : value.trim().length(); + } + + private boolean containsField(JsonNode node, String field) { + if (node == null) { + return false; + } + if (node.isObject()) { + if (node.has(field)) { + return true; + } + for (JsonNode child : node) { + if (containsField(child, field)) { + return true; + } + } + } + if (node.isArray()) { + for (JsonNode child : node) { + if (containsField(child, field)) { + return true; + } + } + } + return false; + } + + private String writeJson(Object value) { + try { + return jackson.writeValueAsString(value); + } catch (Exception e) { + throw new GaoAiToolException("工具结果序列化失败", 500); + } + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpOAuthDiscoveryController.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpOAuthDiscoveryController.java new file mode 100644 index 0000000..f65ca7b --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiMcpOAuthDiscoveryController.java @@ -0,0 +1,37 @@ +package com.imyeyu.api.modules.gao.ai.mcp; + +import com.imyeyu.spring.annotation.IgnoreGlobalReturn; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/// GAO MCP OAuth 探测兼容接口 +/// +/// Dify 即使配置固定请求头认证,也会预先探测 OAuth 元数据。本服务不提供 OAuth,必须返回原始 404, +/// 不能落入全局异常处理后被包装为业务 JSON +/// +/// @author Codex +/// @since 2026-09-15 +@RestController +public class GaoAiMcpOAuthDiscoveryController { + + /// 返回未实现的 OAuth 探测结果 + /// + /// @return 原始 HTTP 404 + @IgnoreGlobalReturn + @GetMapping({ + "/.well-known/oauth-authorization-server", + "/.well-known/oauth-authorization-server/mcp/gao-ai", + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/mcp/gao-ai", + "/.well-known/openid-configuration", + "/.well-known/openid-configuration/mcp/gao-ai", + "/mcp/gao-ai/.well-known/oauth-authorization-server", + "/mcp/gao-ai/.well-known/oauth-protected-resource", + "/mcp/gao-ai/.well-known/openid-configuration" + }) + public ResponseEntity notSupported() { + return ResponseEntity.notFound().cacheControl(CacheControl.noStore()).build(); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiToolNames.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiToolNames.java new file mode 100644 index 0000000..d6f94d8 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/GaoAiToolNames.java @@ -0,0 +1,46 @@ +package com.imyeyu.api.modules.gao.ai.mcp; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/// GAO 智能体只读工具名称 +/// +/// @author Codex +/// @since 2026-09-15 +public final class GaoAiToolNames { + + public static final String STORE_OVERVIEW = "store_overview"; + public static final String CUSTOMER_SEARCH = "customer_search"; + public static final String CUSTOMER_PROFILE = "customer_profile"; + public static final String ORDER_QUERY = "order_query"; + public static final String EVENT_QUERY = "event_query"; + public static final String PRODUCT_INVENTORY_QUERY = "product_inventory_query"; + public static final String POINT_QUERY = "point_query"; + public static final String BUSINESS_REPORT = "business_report"; + public static final String STORE_DIARY_QUERY = "store_diary_query"; + public static final String METRIC_DEFINITION = "metric_definition"; + + public static final Set READ_ONLY_TOOLS = java.util.Collections.unmodifiableSet(new LinkedHashSet<>(List.of( + STORE_OVERVIEW, + CUSTOMER_SEARCH, + CUSTOMER_PROFILE, + ORDER_QUERY, + EVENT_QUERY, + PRODUCT_INVENTORY_QUERY, + POINT_QUERY, + BUSINESS_REPORT, + STORE_DIARY_QUERY, + METRIC_DEFINITION + ))); + + private GaoAiToolNames() { + } + + /// 生成稳定的工具顺序 + /// + /// @return 工具名称集合 + public static Set orderedReadOnlyTools() { + return new LinkedHashSet<>(READ_ONLY_TOOLS); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/BusinessReportTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/BusinessReportTool.java new file mode 100644 index 0000000..93be855 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/BusinessReportTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 门店经营报表工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class BusinessReportTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.BUSINESS_REPORT; + } + + @Override + public String description() { + return "查询当前门店销售趋势、应收、客户复购和产品排行"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.businessReport(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/CustomerProfileTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/CustomerProfileTool.java new file mode 100644 index 0000000..a3d9d8a --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/CustomerProfileTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 客户经营档案工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class CustomerProfileTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.CUSTOMER_PROFILE; + } + + @Override + public String description() { + return "查询当前门店单个客户的套餐、积分、消费和事件档案"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.customerProfile(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/CustomerTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/CustomerTool.java new file mode 100644 index 0000000..d9d7d96 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/CustomerTool.java @@ -0,0 +1,37 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 客户查询工具 +/// +/// 同时承载客户搜索和客户经营档案查询 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class CustomerTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.CUSTOMER_SEARCH; + } + + @Override + public String description() { + return "按客户姓名或编码查询当前门店客户摘要"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.customerSearch(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/EventTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/EventTool.java new file mode 100644 index 0000000..6125c78 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/EventTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 登记事件查询工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class EventTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.EVENT_QUERY; + } + + @Override + public String description() { + return "查询当前门店登记事件记录及事件统计,严格只读"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.eventQuery(session, request); + } +} 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 new file mode 100644 index 0000000..ad23182 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/GaoAiTool.java @@ -0,0 +1,51 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +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.Map; + +/// GAO 智能体只读工具 +/// +/// @author Codex +/// @since 2026-09-15 +public interface GaoAiTool { + + /// @return 工具名称 + String name(); + + /// @return 工具描述 + String description(); + + /// 执行工具查询 + /// + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 脱敏查询结果 + GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request); + + /// @return MCP 输入参数结构 + default Map inputSchema() { + Map timeRange = Map.of( + "type", "object", + "properties", Map.of( + "preset", Map.of("type", "string", "enum", new String[]{"TODAY", "LAST_7_DAYS", "LAST_30_DAYS", "THIS_MONTH", "THIS_YEAR", "CUSTOM"}), + "startDate", Map.of("type", "string", "description", "自定义开始日期,格式 yyyy-MM-dd"), + "endDate", Map.of("type", "string", "description", "自定义结束日期,格式 yyyy-MM-dd") + ), + "additionalProperties", false + ); + 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") + ), + "additionalProperties", false + ); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/MetricDefinitionTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/MetricDefinitionTool.java new file mode 100644 index 0000000..e46855a --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/MetricDefinitionTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 指标口径查询工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class MetricDefinitionTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.METRIC_DEFINITION; + } + + @Override + public String description() { + return "查询门店经营助手使用的固定指标名称和计算口径"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.metricDefinition(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/OrderTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/OrderTool.java new file mode 100644 index 0000000..46591ea --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/OrderTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 订单和资金查询工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class OrderTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.ORDER_QUERY; + } + + @Override + public String description() { + return "查询当前门店销售订单、履约和资金摘要,严格只读"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.orderQuery(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/PointTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/PointTool.java new file mode 100644 index 0000000..66d0bde --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/PointTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 积分查询工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class PointTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.POINT_QUERY; + } + + @Override + public String description() { + return "查询当前门店积分账户、积分流水、规则和兑换摘要,严格只读"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.pointQuery(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/ProductInventoryTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/ProductInventoryTool.java new file mode 100644 index 0000000..f808e8e --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/ProductInventoryTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 产品库存查询工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class ProductInventoryTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.PRODUCT_INVENTORY_QUERY; + } + + @Override + public String description() { + return "查询当前门店产品、SKU 和库存余额,严格只读"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.productInventoryQuery(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/StoreDiaryTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/StoreDiaryTool.java new file mode 100644 index 0000000..138840e --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/StoreDiaryTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 门店日记查询工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class StoreDiaryTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.STORE_DIARY_QUERY; + } + + @Override + public String description() { + return "查询当前门店日记和营业日信息,严格只读"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.storeDiaryQuery(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/StoreOverviewTool.java b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/StoreOverviewTool.java new file mode 100644 index 0000000..86ad099 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/mcp/tool/StoreOverviewTool.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.mcp.tool; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/// 门店经营总览工具 +/// +/// @author Codex +/// @since 2026-09-15 +@Component +@RequiredArgsConstructor +public class StoreOverviewTool implements GaoAiTool { + + private final GaoAiQueryService queryService; + + @Override + public String name() { + return GaoAiToolNames.STORE_OVERVIEW; + } + + @Override + public String description() { + return "查询当前门店指定周期的营业日、销售、应收和库存健康总览"; + } + + @Override + public GaoAiToolResult execute(GaoAiSession session, GaoAiToolRequest request) { + return queryService.storeOverview(session, request); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiAccessException.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiAccessException.java new file mode 100644 index 0000000..93b5720 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiAccessException.java @@ -0,0 +1,22 @@ +package com.imyeyu.api.modules.gao.ai.service; + +/// GAO 智能体访问异常 +/// +/// @author Codex +/// @since 2026-09-15 +public class GaoAiAccessException extends RuntimeException { + + private final int status; + + /// @param message 用户可读消息 + /// @param status HTTP 状态码 + public GaoAiAccessException(String message, int status) { + super(message); + this.status = status; + } + + /// @return HTTP 状态码 + public int getStatus() { + return status; + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiAuditService.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiAuditService.java new file mode 100644 index 0000000..788f63f --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiAuditService.java @@ -0,0 +1,21 @@ +package com.imyeyu.api.modules.gao.ai.service; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiAuditLog; + +/// GAO 智能体审计服务 +/// +/// @author Codex +/// @since 2026-09-15 +public interface GaoAiAuditService { + + /// 记录脱敏后的 AI 查询审计 + /// + /// @param auditLog 审计记录 + void record(GaoAiAuditLog auditLog); + + /// 计算问题摘要哈希 + /// + /// @param question 问题原文 + /// @return SHA-256 摘要 + String digest(String question); +} 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 new file mode 100644 index 0000000..779cf05 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiChatService.java @@ -0,0 +1,17 @@ +package com.imyeyu.api.modules.gao.ai.service; + +import com.imyeyu.api.modules.gao.ai.vo.GaoAiChatRequest; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/// GAO 智能体聊天服务 +/// +/// @author Codex +/// @since 2026-09-15 +public interface GaoAiChatService { + + /// 创建 AI 会话并转发 Dify 流式响应 + /// + /// @param request 聊天请求 + /// @return SSE 发射器 + SseEmitter chat(GaoAiChatRequest request); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiQueryService.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiQueryService.java new file mode 100644 index 0000000..dbac084 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiQueryService.java @@ -0,0 +1,62 @@ +package com.imyeyu.api.modules.gao.ai.service; + +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; + +/// GAO 智能体只读查询服务 +/// +/// @author Codex +/// @since 2026-09-15 +public interface GaoAiQueryService { + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 门店经营总览 + GaoAiToolResult storeOverview(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 客户搜索结果 + GaoAiToolResult customerSearch(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 客户经营档案 + GaoAiToolResult customerProfile(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 订单查询结果 + GaoAiToolResult orderQuery(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 事件查询结果 + GaoAiToolResult eventQuery(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 产品库存查询结果 + GaoAiToolResult productInventoryQuery(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 积分查询结果 + GaoAiToolResult pointQuery(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 经营报表结果 + GaoAiToolResult businessReport(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 门店日记查询结果 + GaoAiToolResult storeDiaryQuery(GaoAiSession session, GaoAiToolRequest request); + + /// @param session 已认证 AI 会话 + /// @param request 工具请求 + /// @return 指标口径 + GaoAiToolResult metricDefinition(GaoAiSession session, GaoAiToolRequest request); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiSessionService.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiSessionService.java new file mode 100644 index 0000000..012311f --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiSessionService.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.ai.service; + +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; + +/// GAO 智能体会话服务 +/// +/// @author Codex +/// @since 2026-09-15 +public interface GaoAiSessionService { + + /// 创建绑定用户和门店的 AI 会话 + /// + /// @param userId 用户 ID + /// @param storeId 门店 ID + /// @param difyConversationId Dify 会话 ID,可为空 + /// @return AI 会话 + GaoAiSession create(String userId, String storeId, String difyConversationId); + + /// 获取有效 AI 会话 + /// + /// @param sessionId AI 会话 ID + /// @return AI 会话 + GaoAiSession require(String sessionId); + + /// 增加工具调用次数 + /// + /// @param sessionId AI 会话 ID + /// @return 更新后的会话 + GaoAiSession increaseUsedCount(String sessionId); + + /// 删除 AI 会话 + /// + /// @param sessionId AI 会话 ID + void delete(String sessionId); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiToolException.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiToolException.java new file mode 100644 index 0000000..acf599c --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/GaoAiToolException.java @@ -0,0 +1,22 @@ +package com.imyeyu.api.modules.gao.ai.service; + +/// GAO 智能体工具请求异常 +/// +/// @author Codex +/// @since 2026-09-15 +public class GaoAiToolException extends RuntimeException { + + private final int status; + + /// @param message 异常消息 + /// @param status HTTP 状态码 + public GaoAiToolException(String message, int status) { + super(message); + this.status = status; + } + + /// @return HTTP 状态码 + public int getStatus() { + return status; + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiAuditServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiAuditServiceImplement.java new file mode 100644 index 0000000..de1a665 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiAuditServiceImplement.java @@ -0,0 +1,78 @@ +package com.imyeyu.api.modules.gao.ai.service.implement; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imyeyu.api.modules.gao.ai.entity.GaoAiAuditLog; +import com.imyeyu.api.modules.gao.ai.service.GaoAiAuditService; +import com.imyeyu.api.modules.system.entity.Logger; +import com.imyeyu.api.modules.system.service.LoggerService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.LinkedHashMap; +import java.util.Map; + +/// GAO 智能体审计服务实现 +/// +/// @author Codex +/// @since 2026-09-15 +@Slf4j +@Service +@RequiredArgsConstructor +public class GaoAiAuditServiceImplement implements GaoAiAuditService { + + private final ObjectMapper jackson; + private final LoggerService loggerService; + + @Override + public void record(GaoAiAuditLog auditLog) { + if (auditLog == null) { + return; + } + Map content = new LinkedHashMap<>(); + content.put("storeId", auditLog.getStoreId()); + content.put("questionDigest", auditLog.getQuestionDigest()); + content.put("conversationId", auditLog.getConversationId()); + content.put("toolName", auditLog.getToolName()); + content.put("duration", auditLog.getDuration()); + content.put("status", auditLog.getStatus()); + content.put("resultCount", auditLog.getResultCount()); + content.put("createdAt", auditLog.getCreatedAt()); + try { + Logger logger = new Logger(Logger.Module.GAO, auditLevel(auditLog.getStatus()), "GAO_AI_QUERY"); + logger.setUserId(auditLog.getUserId()); + logger.setContent(jackson.writeValueAsString(content)); + logger.setResult(auditLog.getStatus()); + logger.setDuration((int) Math.min(Integer.MAX_VALUE, Math.max(0L, auditLog.getDuration()))); + loggerService.create(logger); + } catch (JsonProcessingException | RuntimeException e) { + log.warn("写入 GAO 智能体审计日志失败", e); + } + } + + /// 计算问题摘要哈希 + /// + /// @param question 问题原文 + /// @return SHA-256 摘要 + public String digest(String question) { + try { + byte[] bytes = MessageDigest.getInstance("SHA-256").digest( + (question == null ? "" : question).getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + result.append("%02x".formatted(value & 0xff)); + } + return result.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("当前 JDK 不支持 SHA-256", e); + } + } + + private Logger.Level auditLevel(String status) { + return "SUCCESS".equals(status) ? Logger.Level.INFO : Logger.Level.WARN; + } +} 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 new file mode 100644 index 0000000..464c854 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiChatServiceImplement.java @@ -0,0 +1,465 @@ +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.ObjectNode; +import com.imyeyu.api.bean.ModuleCode; +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.GaoAiAuditLog; +import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; +import com.imyeyu.api.modules.gao.ai.service.GaoAiAccessException; +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.bean.GaoRoleCode; +import com.imyeyu.api.modules.gao.bean.GaoSystemSetting; +import com.imyeyu.api.modules.gao.entity.GaoStore; +import com.imyeyu.api.modules.gao.service.GaoStoreScopeService; +import com.imyeyu.api.modules.gao.service.GaoStoreService; +import com.imyeyu.api.modules.system.bean.DifySetting; +import com.imyeyu.api.modules.user.entity.User; +import com.imyeyu.api.modules.user.service.RoleChecker; +import com.imyeyu.api.modules.user.service.UserLoginService; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/// GAO 智能体聊天服务实现 +/// +/// 使用 JDK HttpClient 读取 Dify SSE,再通过 Spring MVC SseEmitter 输出给前端 +/// +/// @author Codex +/// @since 2026-09-15 +@Slf4j +@Service +@RequiredArgsConstructor +public class GaoAiChatServiceImplement implements GaoAiChatService { + + private static final ZoneId ZONE = ZoneId.systemDefault(); + + private final ObjectMapper jackson; + private final GaoAiProperties properties; + private final UserLoginService userLoginService; + private final RoleChecker roleChecker; + private final GaoStoreScopeService storeScopeService; + private final GaoStoreService storeService; + private final GaoAiSessionService sessionService; + private final GaoAiAuditService auditService; + private final SettingService settingService; + + private HttpClient httpClient; + private ExecutorService streamExecutor; + private ScheduledExecutorService timeoutExecutor; + + @PostConstruct + private void postConstruct() { + this.streamExecutor = Executors.newVirtualThreadPerTaskExecutor(); + this.timeoutExecutor = Executors.newSingleThreadScheduledExecutor(Thread.ofVirtual().factory()); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(positive(properties.getConnectTimeoutSeconds(), 10L))) + .executor(streamExecutor) + .version(HttpClient.Version.HTTP_1_1) + .build(); + } + + @Override + public SseEmitter chat(GaoAiChatRequest request) { + if (request == null || isBlank(request.getMessage())) { + throw new GaoAiAccessException("问题不能为空", 400); + } + String message = request.getMessage().trim(); + if (256 < message.length()) { + throw new GaoAiAccessException("问题长度不能超过 256 个字符", 400); + } + 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); + } + String storeId; + try { + storeId = storeScopeService.resolveStoreId(request.getStoreId()); + } catch (RuntimeException e) { + throw new GaoAiAccessException("当前账号没有可用的所属门店", 403); + } + GaoStore store = storeId == null ? null : storeService.get(storeId); + if (store == null || !store.isActivated()) { + throw new GaoAiAccessException("当前账号没有可用的所属门店", 403); + } + GaoAiSession session = sessionService.create(user.getId(), storeId, request.getConversationId()); + return stream(session, message); + } + + 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); + SseEmitter emitter = new SseEmitter(emitterTimeout); + AtomicBoolean finished = new AtomicBoolean(); + AtomicBoolean auditRecorded = new AtomicBoolean(); + AtomicReference status = new AtomicReference<>("CANCELLED"); + AtomicReference>> upstreamRef = new AtomicReference<>(); + AtomicReference> timeoutRef = new AtomicReference<>(); + long startedAt = System.currentTimeMillis(); + + Runnable cancel = () -> { + status.compareAndSet("CANCELLED", "CANCELLED"); + finished.set(true); + CompletableFuture> upstream = upstreamRef.get(); + if (upstream != null) { + upstream.cancel(true); + } + cancelTimeout(timeoutRef); + recordAudit(session, message, status.get(), startedAt, auditRecorded); + }; + emitter.onCompletion(cancel); + emitter.onTimeout(cancel); + emitter.onError(error -> cancel.run()); + + try { + HttpRequest httpRequest = buildRequest(session, message, readTimeoutSeconds); + CompletableFuture> upstream = httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofInputStream()); + upstreamRef.set(upstream); + if (finished.get()) { + upstream.cancel(true); + return emitter; + } + touchTimeout(timeoutRef, readTimeoutSeconds, () -> timeout(emitter, finished, upstreamRef, timeoutRef, status, auditRecorded, session, message, startedAt)); + upstream.thenAcceptAsync(response -> readResponse(response, emitter, finished, upstreamRef, timeoutRef, + status, auditRecorded, session, message, startedAt, readTimeoutSeconds), streamExecutor) + .exceptionally(error -> { + if (!finished.get()) { + logStreamError("异步请求", session, error); + status.set("ERROR"); + emitError(emitter, "DIFY_UNAVAILABLE", "智能体服务暂时不可用,请稍后重试"); + safeComplete(emitter, finished, timeoutRef, auditRecorded, session, message, startedAt, status); + } + return null; + }); + } catch (RuntimeException e) { + logStreamError("创建请求", session, e); + status.set("ERROR"); + emitError(emitter, "DIFY_REQUEST_ERROR", "智能体服务暂时不可用,请稍后重试"); + safeComplete(emitter, finished, timeoutRef, auditRecorded, session, message, startedAt, status); + } + return emitter; + } + + private HttpRequest buildRequest(GaoAiSession session, String message, long readTimeoutSeconds) { + ObjectNode body = jackson.createObjectNode(); + body.put("query", message); + body.put("response_mode", "streaming"); + body.put("user", "gao-store-manager:" + session.getUserId()); + if (!isBlank(session.getDifyConversationId())) { + body.put("conversation_id", session.getDifyConversationId()); + } + ObjectNode inputs = body.putObject("inputs"); + inputs.put("query", message); + inputs.put("aiSessionId", session.getSessionId()); + 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")) + .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") + .POST(HttpRequest.BodyPublishers.ofString(writeJson(body), StandardCharsets.UTF_8)) + .build(); + } + + private void readResponse(HttpResponse response, SseEmitter emitter, AtomicBoolean finished, + AtomicReference>> upstreamRef, AtomicReference> timeoutRef, + AtomicReference status, AtomicBoolean auditRecorded, GaoAiSession session, String message, long startedAt, + long readTimeoutSeconds) { + try (InputStream stream = response.body()) { + if (response.statusCode() < 200 || 299 < response.statusCode()) { + String responseBody = readErrorResponseBody(stream); + log.error("GAO 智能体返回异常状态:statusCode={}, sessionId={}, userId={}, storeId={}", + response.statusCode(), session.getSessionId(), session.getUserId(), session.getStoreId()); + log.error("GAO 智能体错误响应体:sessionId={}, responseBody={}", session.getSessionId(), responseBody); + status.set("ERROR"); + emitError(emitter, "DIFY_HTTP_ERROR", "智能体服务暂时不可用,请稍后重试"); + safeComplete(emitter, finished, timeoutRef, auditRecorded, session, message, startedAt, status); + return; + } + AtomicReference sseEvent = new AtomicReference<>(); + StringBuilder data = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while (!finished.get() && (line = reader.readLine()) != null) { + touchTimeout(timeoutRef, positive(properties.getReadTimeoutSeconds(), 600L), + () -> timeout(emitter, finished, upstreamRef, timeoutRef, status, auditRecorded, session, message, startedAt)); + if (line.startsWith("event:")) { + sseEvent.set(line.substring(6).trim()); + } else if (line.startsWith("data:")) { + data.append(line.substring(5).trim()); + } else if (line.isEmpty() && !data.isEmpty()) { + processData(data.toString(), sseEvent.get(), emitter, status, finished, timeoutRef, auditRecorded, + session, message, startedAt); + data.setLength(0); + sseEvent.set(null); + } + } + if (!finished.get() && !data.isEmpty()) { + processData(data.toString(), sseEvent.get(), emitter, status, finished, timeoutRef, auditRecorded, + session, message, startedAt); + } + } + if (!finished.get()) { + status.compareAndSet("CANCELLED", "SUCCESS"); + emitDone(emitter, session, startedAt, finished, timeoutRef, auditRecorded, message, status); + } + } catch (IOException | RuntimeException e) { + if (!finished.get()) { + logStreamError("读取响应", session, e); + status.set("ERROR"); + emitError(emitter, "DIFY_STREAM_ERROR", "智能体连接中断,请重试"); + safeComplete(emitter, finished, timeoutRef, auditRecorded, session, message, startedAt, status); + } + } + } + + /// 读取并压缩 Dify 错误响应体 + /// + /// @param stream Dify 响应流 + /// @return 错误响应内容 + /// @throws IOException 读取响应体失败 + private String readErrorResponseBody(InputStream stream) throws IOException { + int maxLength = 8192; + byte[] bytes = stream.readNBytes(maxLength); + String body = new String(bytes, StandardCharsets.UTF_8) + .replace('\r', ' ') + .replace('\n', ' ') + .replace('\t', ' ') + .trim(); + return maxLength <= bytes.length ? body + "..." : body; + } + + private void processData(String rawData, String sseEvent, SseEmitter emitter, AtomicReference status, + AtomicBoolean finished, AtomicReference> timeoutRef, AtomicBoolean auditRecorded, + GaoAiSession session, String message, long startedAt) { + if ("[DONE]".equals(rawData)) { + return; + } + JsonNode payload; + try { + payload = jackson.readTree(rawData); + } catch (IOException e) { + log.error("GAO 智能体 SSE 数据解析失败:sessionId={}, userId={}, storeId={}, dataLength={}", + session.getSessionId(), session.getUserId(), session.getStoreId(), rawData.length(), e); + return; + } + String event = firstText(text(payload, "event", null), sseEvent, "message"); + String conversationId = firstText(text(payload, "conversation_id", null), text(payload, "conversationId", null)); + if (conversationId != null) { + session.setDifyConversationId(conversationId); + } + switch (event) { + case "message", "agent_message", "text_chunk" -> { + String answer = firstText(text(payload, "answer", null), text(payload, "text", null)); + if (answer != null) { + safeSend(emitter, SseEmitter.event().name("message").data(answer), finished); + } + } + case "message_end" -> emitDone(emitter, session, startedAt, finished, timeoutRef, auditRecorded, message, status); + case "node_started", "node_finished", "workflow_started", "workflow_finished" -> { + Map data = new LinkedHashMap<>(); + data.put("tool", "实时数据查询"); + data.put("status", event.endsWith("finished") ? "finished" : "started"); + safeSend(emitter, SseEmitter.event().name("tool_status").data(data), finished); + } + case "message_file", "retriever_resources" -> { + Map data = new LinkedHashMap<>(); + data.put("source", "实时业务数据或业务知识库"); + safeSend(emitter, SseEmitter.event().name("citation").data(data), finished); + } + case "error" -> { + status.set("ERROR"); + emitError(emitter, "DIFY_ERROR", "智能体查询失败,请稍后重试"); + safeComplete(emitter, finished, timeoutRef, auditRecorded, session, message, startedAt, status); + } + default -> { + // 未识别的 Dify 事件不透传原始内容,避免泄露内部节点和参数 + } + } + } + + private void emitDone(SseEmitter emitter, GaoAiSession session, long startedAt, AtomicBoolean finished, + AtomicReference> timeoutRef, AtomicBoolean auditRecorded, String message, + AtomicReference status) { + if (finished.get()) { + return; + } + Map data = new LinkedHashMap<>(); + data.put("conversationId", session.getDifyConversationId()); + data.put("duration", Math.max(0L, System.currentTimeMillis() - startedAt)); + safeSend(emitter, SseEmitter.event().name("done").data(data), finished); + status.compareAndSet("CANCELLED", "SUCCESS"); + safeComplete(emitter, finished, timeoutRef, auditRecorded, session, message, startedAt, status); + } + + private void emitError(SseEmitter emitter, String code, String message) { + Map data = new LinkedHashMap<>(); + data.put("code", code); + data.put("message", message); + try { + emitter.send(SseEmitter.event().name("error").data(data)); + } catch (IOException | IllegalStateException ignored) { + // 浏览器已断开时无需再次写入 + } + } + + private boolean safeSend(SseEmitter emitter, SseEmitter.SseEventBuilder event, AtomicBoolean finished) { + if (finished.get()) { + return false; + } + try { + emitter.send(event); + return true; + } catch (IOException | IllegalStateException e) { + finished.set(true); + return false; + } + } + + private void safeComplete(SseEmitter emitter, AtomicBoolean finished, AtomicReference> timeoutRef, + AtomicBoolean auditRecorded, GaoAiSession session, String message, long startedAt, AtomicReference status) { + if (!finished.compareAndSet(false, true)) { + return; + } + cancelTimeout(timeoutRef); + recordAudit(session, message, status.get(), startedAt, auditRecorded); + try { + emitter.complete(); + } catch (RuntimeException ignored) { + // SSE 已经关闭 + } + } + + private void timeout(SseEmitter emitter, AtomicBoolean finished, + AtomicReference>> upstreamRef, + AtomicReference> timeoutRef, AtomicReference status, + AtomicBoolean auditRecorded, GaoAiSession session, String message, long startedAt) { + if (finished.get()) { + return; + } + status.set("TIMEOUT"); + CompletableFuture> upstream = upstreamRef.get(); + if (upstream != null) { + upstream.cancel(true); + } + emitError(emitter, "DIFY_TIMEOUT", "数据查询超时,请缩小时间范围后重试"); + safeComplete(emitter, finished, timeoutRef, auditRecorded, session, message, startedAt, status); + } + + private void touchTimeout(AtomicReference> timeoutRef, long seconds, Runnable action) { + ScheduledFuture old = timeoutRef.getAndSet(timeoutExecutor.schedule(action, seconds, TimeUnit.SECONDS)); + if (old != null) { + old.cancel(false); + } + } + + private void cancelTimeout(AtomicReference> timeoutRef) { + ScheduledFuture timeout = timeoutRef.getAndSet(null); + if (timeout != null) { + timeout.cancel(false); + } + } + + private void recordAudit(GaoAiSession session, String message, String status, long startedAt, AtomicBoolean recorded) { + if (!recorded.compareAndSet(false, true)) { + return; + } + GaoAiAuditLog audit = new GaoAiAuditLog(); + audit.setUserId(session.getUserId()); + audit.setStoreId(session.getStoreId()); + audit.setQuestionDigest(auditService.digest(message)); + audit.setConversationId(session.getDifyConversationId()); + audit.setToolName("dify_chatflow"); + audit.setDuration(Math.max(0L, System.currentTimeMillis() - startedAt)); + audit.setStatus(status); + audit.setResultCount(0L); + audit.setCreatedAt(System.currentTimeMillis()); + auditService.record(audit); + } + + /// 记录智能体流式请求异常 + /// + /// @param phase 异常阶段 + /// @param session 智能体会话 + /// @param error 异常 + /// @return 无 + private void logStreamError(String phase, GaoAiSession session, Throwable error) { + log.error("GAO 智能体流式请求异常:phase={}, sessionId={}, userId={}, storeId={}", + phase, session.getSessionId(), session.getUserId(), session.getStoreId(), error); + } + + private String writeJson(ObjectNode body) { + try { + return jackson.writeValueAsString(body); + } catch (IOException e) { + throw new IllegalStateException("无法生成 Dify 请求", e); + } + } + + private String text(JsonNode node, String field, String fallback) { + if (node != null && node.hasNonNull(field)) { + String value = node.get(field).asText(); + return isBlank(value) ? fallback : value; + } + return fallback; + } + + private String firstText(String... values) { + for (String value : values) { + if (!isBlank(value)) { + return value; + } + } + return null; + } + + private long positive(long value, long fallback) { + return 0 < value ? value : fallback; + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiQueryServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiQueryServiceImplement.java new file mode 100644 index 0000000..9c2b36c --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiQueryServiceImplement.java @@ -0,0 +1,783 @@ +package com.imyeyu.api.modules.gao.ai.service.implement; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imyeyu.api.modules.common.entity.Article; +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.GaoAiQueryService; +import com.imyeyu.api.modules.gao.ai.service.GaoAiToolException; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; +import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; +import com.imyeyu.api.modules.gao.entity.GaoCustomer; +import com.imyeyu.api.modules.gao.entity.GaoCustomerConsumptionRecord; +import com.imyeyu.api.modules.gao.entity.GaoCustomerPackage; +import com.imyeyu.api.modules.gao.entity.GaoEvent; +import com.imyeyu.api.modules.gao.entity.GaoEventRecord; +import com.imyeyu.api.modules.gao.entity.GaoFundRecord; +import com.imyeyu.api.modules.gao.entity.GaoPointAccount; +import com.imyeyu.api.modules.gao.entity.GaoPointLedger; +import com.imyeyu.api.modules.gao.entity.GaoProductStock; +import com.imyeyu.api.modules.gao.entity.GaoSalesOrder; +import com.imyeyu.api.modules.gao.entity.GaoStore; +import com.imyeyu.api.modules.gao.entity.GaoStoreBusinessDay; +import com.imyeyu.api.modules.gao.service.GaoCustomerConsumptionRecordService; +import com.imyeyu.api.modules.gao.service.GaoCustomerPackageService; +import com.imyeyu.api.modules.gao.service.GaoCustomerService; +import com.imyeyu.api.modules.gao.service.GaoEventRecordService; +import com.imyeyu.api.modules.gao.service.GaoEventService; +import com.imyeyu.api.modules.gao.service.GaoFundRecordService; +import com.imyeyu.api.modules.gao.service.GaoPointAccountService; +import com.imyeyu.api.modules.gao.service.GaoPointLedgerService; +import com.imyeyu.api.modules.gao.service.GaoPointRedemptionService; +import com.imyeyu.api.modules.gao.service.GaoPointRuleService; +import com.imyeyu.api.modules.gao.service.GaoProductChartService; +import com.imyeyu.api.modules.gao.service.GaoProductStockService; +import com.imyeyu.api.modules.gao.service.GaoSalesOrderChartService; +import com.imyeyu.api.modules.gao.service.GaoSalesOrderService; +import com.imyeyu.api.modules.gao.service.GaoStoreBusinessDayService; +import com.imyeyu.api.modules.gao.service.GaoStoreDiaryService; +import com.imyeyu.api.modules.gao.service.GaoStoreService; +import com.imyeyu.api.modules.gao.vo.GaoCustomerPage; +import com.imyeyu.api.modules.gao.vo.GaoCustomerPackagePage; +import com.imyeyu.api.modules.gao.vo.GaoEventRecordPage; +import com.imyeyu.api.modules.gao.vo.GaoFundRecordPage; +import com.imyeyu.api.modules.gao.vo.GaoPointLedgerPage; +import com.imyeyu.api.modules.gao.vo.GaoPointRedemptionPage; +import com.imyeyu.api.modules.gao.vo.GaoProductChartReq; +import com.imyeyu.api.modules.gao.vo.GaoProductStockPage; +import com.imyeyu.api.modules.gao.vo.GaoSalesOrderChartReq; +import com.imyeyu.api.modules.gao.vo.GaoSalesOrderPage; +import com.imyeyu.api.modules.gao.vo.GaoStoreDiaryPage; +import com.imyeyu.spring.bean.PageResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/// GAO 智能体只读查询服务实现 +/// +/// 所有查询都将会话中的门店 ID 写入既有查询请求,不接受工具参数中的门店范围 +/// +/// @author Codex +/// @since 2026-09-15 +@Service +@RequiredArgsConstructor +public class GaoAiQueryServiceImplement implements GaoAiQueryService { + + private static final ZoneId ZONE = ZoneId.systemDefault(); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE; + private static final DateTimeFormatter DAY_VALUE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final Set SENSITIVE_KEYS = Set.of( + "id", + "storeId", + "userId", + "customerId", + "operatorUserId", + "verifierUserId", + "creatorUserId", + "updatedBy", + "cancelOperatorUserId", + "sourcePayload", + "attachmentList", + "skuSpecSnapshot", + "password", + "token", + "apiKey", + "phone", + "mobile", + "telephone", + "email", + "address" + ); + + private final ObjectMapper jackson; + private final GaoAiProperties properties; + private final GaoStoreService storeService; + private final GaoStoreBusinessDayService businessDayService; + private final GaoCustomerService customerService; + private final GaoCustomerPackageService customerPackageService; + private final GaoCustomerConsumptionRecordService consumptionRecordService; + private final GaoEventService eventService; + private final GaoEventRecordService eventRecordService; + private final GaoSalesOrderService salesOrderService; + private final GaoSalesOrderChartService salesOrderChartService; + private final GaoFundRecordService fundRecordService; + private final GaoProductStockService stockService; + private final GaoProductChartService productChartService; + private final GaoPointAccountService pointAccountService; + private final GaoPointLedgerService pointLedgerService; + private final GaoPointRuleService pointRuleService; + private final GaoPointRedemptionService redemptionService; + private final GaoStoreDiaryService storeDiaryService; + + @Override + public GaoAiToolResult storeOverview(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "TODAY", false); + GaoStore store = requireStore(session); + + GaoSalesOrderChartReq salesRequest = salesChartRequest(session, window, request); + List daily = salesOrderChartService.daily(salesRequest); + Object receivable = salesOrderChartService.receivable(salesRequest); + GaoProductChartReq productRequest = productChartRequest(session, window, request); + List stockHealth = productChartService.stockHealth(productRequest); + List businessDays = businessDayService.listByDateRange( + session.getStoreId(), window.beginDateValue(), window.endDateValue()); + + Map summary = new LinkedHashMap<>(); + summary.put("store", store.getName()); + summary.put("salesDaily", daily); + summary.put("receivable", receivable); + summary.put("businessDays", businessDays); + summary.put("stockHealth", stockHealth); + List alerts = new ArrayList<>(); + for (Object item : stockHealth) { + if (isLowStock(item)) { + alerts.add(item); + } + } + if (alerts.isEmpty() && !stockHealth.isEmpty()) { + alerts.add("当前查询范围未发现低库存产品"); + } + return buildResult(session, window, summary, List.of(), alerts, "门店营业日、销售订单、应收和库存健康"); + } + + @Override + public GaoAiToolResult customerSearch(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "TODAY", false); + GaoCustomerPage page = new GaoCustomerPage(); + page.setStoreId(session.getStoreId()); + page.setKeyword(firstText(request.getQuery(), filter(request, "keyword"))); + page.setBeginCreatedAt(window.begin()); + page.setEndCreatedAt(window.end()); + setPage(page, request); + PageResult result = customerService.pageByQuery(page); + return buildPagedResult(session, window, result, "客户档案", request.getCursor()); + } + + @Override + public GaoAiToolResult customerProfile(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "TODAY", false); + GaoCustomer customer = findCustomer(session.getStoreId(), request); + if (customer == null) { + return buildResult(session, window, Map.of(), List.of(), List.of("当前门店未查到匹配客户"), "客户、套餐、积分、消费和事件档案"); + } + + GaoCustomerPackagePage packagePage = new GaoCustomerPackagePage(); + packagePage.setStoreId(session.getStoreId()); + packagePage.setCustomerId(customer.getId()); + packagePage.setStatus(enumFilter(request, "status", GaoSalesOrder.Status.class)); + packagePage.setIndex(0); + packagePage.setSize(maxPageSize()); + PageResult packages = customerPackageService.pageByQuery(packagePage); + + GaoPointAccount account = pointAccountService.getByCustomerId(session.getStoreId(), customer.getId()); + GaoEventRecordPage eventPage = eventRecordPage(session, window, request); + eventPage.setCustomerId(customer.getId()); + PageResult events = eventRecordService.pageByRange(eventPage); + if (events != null) { + attachEventDisplay(events.getList()); + } + + List consumptions = consumptionRecordService.listActiveByWindow( + session.getStoreId(), customer.getId(), window.begin(), window.end()); + Map summary = new LinkedHashMap<>(); + summary.put("customer", customer); + summary.put("pointAccount", account); + summary.put("packages", packages == null ? List.of() : packages.getList()); + summary.put("events", events == null ? List.of() : events.getList()); + summary.put("activeConsumptions", consumptions); + return buildResult(session, window, summary, List.of(), List.of(), "客户、套餐、积分、消费和事件档案"); + } + + @Override + public GaoAiToolResult orderQuery(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "TODAY", false); + GaoSalesOrderPage page = new GaoSalesOrderPage(); + page.setStoreId(session.getStoreId()); + page.setCustomerId(resolveCustomerId(session.getStoreId(), filter(request, "customerId"))); + page.setOrderType(enumFilter(request, "orderType", GaoSalesOrder.OrderType.class)); + page.setStatus(enumFilter(request, "status", GaoSalesOrder.Status.class)); + page.setPaymentStatus(enumFilter(request, "paymentStatus", GaoSalesOrder.PaymentStatus.class)); + page.setRefundStatus(enumFilter(request, "refundStatus", GaoSalesOrder.RefundStatus.class)); + page.setPendingReceive(booleanFilter(request, "pendingReceive")); + page.setFulfilledButUnpaid(booleanFilter(request, "fulfilledButUnpaid")); + page.setKeyword(firstText(request.getQuery(), filter(request, "keyword"))); + page.setBeginAt(window.begin()); + page.setEndAt(window.end()); + setPage(page, request); + PageResult orders = salesOrderService.pageByQuery(page); + + GaoFundRecordPage fundPage = new GaoFundRecordPage(); + fundPage.setStoreId(session.getStoreId()); + fundPage.setCustomerId(page.getCustomerId()); + fundPage.setBeginAt(window.begin()); + fundPage.setEndAt(window.end()); + fundPage.setIndex(0); + fundPage.setSize(maxPageSize()); + PageResult funds = fundRecordService.pageByQuery(fundPage); + Map summary = new LinkedHashMap<>(); + summary.put("ordersTotal", orders == null ? 0L : orders.getTotal()); + summary.put("fundRecordsTotal", funds == null ? 0L : funds.getTotal()); + summary.put("fundRecords", funds == null ? List.of() : funds.getList()); + return buildResult(session, window, summary, pageDetails(orders), List.of(), "销售订单、履约和资金流水"); + } + + @Override + public GaoAiToolResult eventQuery(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "TODAY", false); + GaoEventRecordPage page = eventRecordPage(session, window, request); + String eventId = filter(request, "eventId"); + if (!isBlank(eventId)) { + GaoEvent event = eventService.get(eventId); + if (event != null && !session.getStoreId().equals(event.getStoreId())) { + throw new GaoAiToolException("事件不属于当前门店", 403); + } + page.setEventIdList(List.of(eventId)); + } + String eventKeyword = firstText(filter(request, "event"), filter(request, "eventName")); + if (eventKeyword != null && isBlank(eventId)) { + List eventIdList = eventService.listValid(session.getStoreId()).stream() + .filter(item -> containsIgnoreCase(item.getName(), eventKeyword)) + .map(GaoEvent::getId) + .toList(); + page.setEventIdList(eventIdList); + } + PageResult records = eventRecordService.pageByRange(page); + attachEventDisplay(records == null ? null : records.getList()); + List details = pageDetails(records); + Map summary = new LinkedHashMap<>(); + summary.put("total", records == null ? 0L : records.getTotal()); + summary.put("returnedCount", details.size()); + summary.put("numberValueTotal", eventRecordService.sumNumberValueByRange(page)); + summary.put("optionStatistics", eventRecordService.optionStatByRange(page)); + if (records != null && details.size() < records.getTotal()) { + summary.put("hasMore", true); + summary.put("nextCursor", String.valueOf(cursorIndex(request.getCursor()) + 1)); + } + return buildResult(session, window, summary, details, List.of(), "登记事件记录和选项统计"); + } + + @Override + public GaoAiToolResult productInventoryQuery(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "TODAY", false); + GaoProductStockPage page = new GaoProductStockPage(); + page.setStoreId(session.getStoreId()); + page.setProductId(filter(request, "productId")); + page.setKeyword(firstText(request.getQuery(), filter(request, "keyword"))); + setPage(page, request); + PageResult stocks = stockService.pageByQuery(page); + GaoProductChartReq productRequest = productChartRequest(session, window, request); + List stockHealth = productChartService.stockHealth(productRequest); + List stockMovementDaily = productChartService.stockMovementDaily(productRequest); + List salesRank = productChartService.salesRank(productRequest); + List details = pageDetails(stocks); + Map summary = new LinkedHashMap<>(); + summary.put("total", stocks == null ? 0L : stocks.getTotal()); + summary.put("returnedCount", details.size()); + summary.put("stockHealth", stockHealth); + summary.put("stockMovementDaily", stockMovementDaily); + summary.put("salesRank", salesRank); + if (stocks != null && details.size() < stocks.getTotal()) { + summary.put("hasMore", true); + summary.put("nextCursor", String.valueOf(cursorIndex(request.getCursor()) + 1)); + } + return buildResult(session, window, summary, details, List.of(), "产品、SKU、库存和销售摘要"); + } + + @Override + public GaoAiToolResult pointQuery(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "TODAY", false); + String customerId = resolveCustomerId(session.getStoreId(), filter(request, "customerId")); + Map summary = new LinkedHashMap<>(); + List details = new ArrayList<>(); + if (customerId == null && request.getQuery() != null) { + customerId = resolveCustomerId(session.getStoreId(), request.getQuery()); + } + if (customerId != null) { + summary.put("account", pointAccountService.getByCustomerId(session.getStoreId(), customerId)); + } + + GaoPointLedgerPage ledgerPage = new GaoPointLedgerPage(); + ledgerPage.setStoreId(session.getStoreId()); + ledgerPage.setCustomerId(customerId); + ledgerPage.setChangeType(enumFilter(request, "changeType", GaoPointLedger.ChangeType.class)); + ledgerPage.setBeginAt(window.begin()); + ledgerPage.setEndAt(window.end()); + setPage(ledgerPage, request); + PageResult ledgers = pointLedgerService.pageByQuery(ledgerPage); + details.addAll(pageDetails(ledgers)); + summary.put("ledgerTotal", ledgers == null ? 0L : ledgers.getTotal()); + + GaoPointRedemptionPage redemptionPage = new GaoPointRedemptionPage(); + redemptionPage.setStoreId(session.getStoreId()); + redemptionPage.setCustomerId(customerId); + redemptionPage.setKeyword(firstText(request.getQuery(), filter(request, "keyword"))); + redemptionPage.setBeginAt(window.begin()); + redemptionPage.setEndAt(window.end()); + redemptionPage.setIndex(0); + redemptionPage.setSize(maxPageSize()); + summary.put("redemptions", redemptionService.pageByQuery(redemptionPage)); + summary.put("activeRules", pointRuleService.listActive(session.getStoreId(), null, null, System.currentTimeMillis())); + return buildResult(session, window, summary, details, List.of(), "积分账户、积分流水、规则和兑换"); + } + + @Override + public GaoAiToolResult businessReport(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "THIS_MONTH", true); + GaoSalesOrderChartReq salesRequest = salesChartRequest(session, window, request); + List salesDaily = salesOrderChartService.daily(salesRequest); + Object receivable = salesOrderChartService.receivable(salesRequest); + List customerRank = salesOrderChartService.customerRank(salesRequest); + List repeatDaily = salesOrderChartService.repeatDaily(salesRequest); + List repeatInterval = salesOrderChartService.repeatInterval(salesRequest); + GaoProductChartReq productRequest = productChartRequest(session, window, request); + List productRank = productChartService.salesRank(productRequest); + Map summary = new LinkedHashMap<>(); + summary.put("salesDaily", salesDaily); + summary.put("receivable", receivable); + summary.put("customerRank", customerRank); + summary.put("repeatDaily", repeatDaily); + summary.put("repeatInterval", repeatInterval); + summary.put("productRank", productRank); + return buildResult(session, window, summary, List.of(), List.of(), "销售趋势、排行、复购和应收分析"); + } + + @Override + public GaoAiToolResult storeDiaryQuery(GaoAiSession session, GaoAiToolRequest request) { + request = normalize(request); + TimeWindow window = resolveWindow(request, "TODAY", false); + GaoStoreDiaryPage page = new GaoStoreDiaryPage(); + page.setStoreId(session.getStoreId()); + page.setBeginCreatedAt(window.begin()); + page.setEndCreatedAt(window.end()); + setPage(page, request); + PageResult
diaries = storeDiaryService.page(page); + return buildPagedResult(session, window, diaries, "门店日记和营业日信息", request.getCursor()); + } + + @Override + public GaoAiToolResult metricDefinition(GaoAiSession session, GaoAiToolRequest request) { + TimeWindow window = resolveWindow(normalize(request), "TODAY", false); + Map summary = new LinkedHashMap<>(); + summary.put("有效销售订单", "状态为 ACTIVE 或 FULFILLED 的销售订单;已取消订单不计入有效销售"); + summary.put("销售额", "有效销售订单的成交金额,单位为分,展示层按金额格式化"); + summary.put("实收金额", "销售订单已确认收款金额,不包含待核销收款"); + summary.put("应收金额", "成交金额减去已确认实收金额"); + summary.put("有效消费", "状态为 ACTIVE 的客户消费事实,仅在订单全额实收后参与累计"); + summary.put("可用库存", "库存现有量减去履约预占量"); + summary.put("复购客户", "统计范围内发生两次及以上有效消费或有效订单的客户,具体以报表工具返回口径为准"); + summary.put("数据边界", "实时业务数据只来自 GAO 查询服务,不包含知识库中的静态规则数据"); + return buildResult(session, window, summary, List.of(), List.of(), "门店经营指标名称和计算口径"); + } + + private GaoAiToolRequest normalize(GaoAiToolRequest request) { + GaoAiToolRequest result = request == null ? new GaoAiToolRequest() : request; + if (result.getFilters() != null && containsStoreId(result.getFilters())) { + throw new GaoAiToolException("工具请求不允许携带 storeId", 403); + } + if (result.getQuery() != null && 256 < result.getQuery().length()) { + throw new GaoAiToolException("查询关键词过长", 400); + } + return result; + } + + private GaoStore requireStore(GaoAiSession session) { + if (session == null || isBlank(session.getStoreId())) { + throw new GaoAiToolException("AI 会话缺少门店范围", 403); + } + GaoStore store = storeService.get(session.getStoreId()); + if (store == null || !store.isActivated()) { + throw new GaoAiToolException("当前门店不存在或未启用", 403); + } + return store; + } + + private GaoCustomer findCustomer(String storeId, GaoAiToolRequest request) { + String customerId = filter(request, "customerId"); + if (!isBlank(customerId)) { + GaoCustomer customer = customerService.get(customerId); + if (customer != null && !storeId.equals(customer.getStoreId())) { + throw new GaoAiToolException("客户不属于当前门店", 403); + } + return customer; + } + String query = request.getQuery(); + if (isBlank(query)) { + return null; + } + GaoCustomer customer = customerService.getByCode(storeId, query); + if (customer == null) { + customer = customerService.getByName(storeId, query); + } + if (customer != null) { + return customer; + } + GaoCustomerPage page = new GaoCustomerPage(); + page.setStoreId(storeId); + page.setKeyword(query); + page.setIndex(0); + page.setSize(1); + PageResult result = customerService.pageByQuery(page); + return result == null || result.getList() == null || result.getList().isEmpty() ? null : result.getList().getFirst(); + } + + private String resolveCustomerId(String storeId, String value) { + if (isBlank(value)) { + return null; + } + GaoCustomer customer = customerService.get(value); + if (customer != null) { + if (!storeId.equals(customer.getStoreId())) { + throw new GaoAiToolException("客户不属于当前门店", 403); + } + return customer.getId(); + } + customer = customerService.getByCode(storeId, value); + if (customer == null) { + customer = customerService.getByName(storeId, value); + } + return customer == null ? null : customer.getId(); + } + + private GaoEventRecordPage eventRecordPage(GaoAiSession session, TimeWindow window, GaoAiToolRequest request) { + GaoEventRecordPage page = new GaoEventRecordPage(); + page.setStoreId(session.getStoreId()); + page.setVerificationStatus(enumFilter(request, "verificationStatus", GaoEventRecord.VerificationStatus.class)); + page.setKeyword(firstText(request.getQuery(), filter(request, "keyword"))); + page.setBeginAt(window.begin()); + page.setEndAt(window.end()); + setPage(page, request); + return page; + } + + private GaoSalesOrderChartReq salesChartRequest(GaoAiSession session, TimeWindow window, GaoAiToolRequest request) { + GaoSalesOrderChartReq result = new GaoSalesOrderChartReq(); + result.setStoreId(session.getStoreId()); + result.setCustomerId(resolveCustomerId(session.getStoreId(), filter(request, "customerId"))); + result.setProductId(filter(request, "productId")); + result.setSkuId(filter(request, "skuId")); + result.setBeginAt(window.begin()); + result.setEndAt(window.end()); + result.setTopN(maxPageSize()); + return result; + } + + private GaoProductChartReq productChartRequest(GaoAiSession session, TimeWindow window, GaoAiToolRequest request) { + GaoProductChartReq result = new GaoProductChartReq(); + result.setStoreId(session.getStoreId()); + result.setProductId(filter(request, "productId")); + result.setSkuId(filter(request, "skuId")); + result.setCategoryId(filter(request, "categoryId")); + result.setBeginAt(window.begin()); + result.setEndAt(window.end()); + result.setTopN(maxPageSize()); + return result; + } + + private void attachEventDisplay(List list) { + if (list == null || list.isEmpty()) { + return; + } + Map eventMap = eventService.mapByIdList(list.stream().map(GaoEventRecord::getEventId).distinct().toList()); + Map customerMap = customerService.mapByIdList(list.stream().map(GaoEventRecord::getCustomerId).distinct().toList()); + for (GaoEventRecord record : list) { + record.setEvent(eventMap.get(record.getEventId())); + record.setCustomer(customerMap.get(record.getCustomerId())); + } + eventRecordService.attachSelectedOptionGroupList(list); + } + + private GaoAiToolResult buildPagedResult(GaoAiSession session, TimeWindow window, PageResult page, String metric, + String cursor) { + List details = pageDetails(page); + Map summary = new LinkedHashMap<>(); + summary.put("total", page == null ? 0L : page.getTotal()); + summary.put("returnedCount", details.size()); + if (page != null && details.size() < page.getTotal()) { + summary.put("hasMore", true); + summary.put("nextCursor", String.valueOf(cursorIndex(cursor) + 1)); + } + return buildResult(session, window, summary, details, List.of(), metric); + } + + private List pageDetails(PageResult page) { + return page == null || page.getList() == null ? new ArrayList<>() : sanitizeList(page.getList()); + } + + private GaoAiToolResult buildResult(GaoAiSession session, TimeWindow window, Map summary, + Collection details, Collection alerts, String metric) { + GaoAiToolResult result = new GaoAiToolResult(); + Map scope = new LinkedHashMap<>(); + GaoStore store = requireStore(session); + scope.put("storeName", store.getName()); + scope.put("timeRange", window.display()); + result.setScope(scope); + result.setSummary(sanitizeMap(summary)); + result.setDetails(sanitizeList(details)); + result.setAlerts(sanitizeList(alerts)); + Map source = new LinkedHashMap<>(); + source.put("metric", metric); + source.put("generatedAt", Instant.now().atZone(ZONE).toOffsetDateTime().toString()); + result.setSource(source); + return result; + } + + private TimeWindow resolveWindow(GaoAiToolRequest request, String defaultPreset, boolean trend) { + String preset = request.getTimeRange() == null ? defaultPreset : request.getTimeRange().getPreset(); + if (isBlank(preset)) { + preset = defaultPreset; + } + preset = preset.trim().toUpperCase(Locale.ROOT); + LocalDate today = LocalDate.now(ZONE); + LocalDate start; + LocalDate end; + switch (preset) { + case "TODAY" -> { + start = today; + end = today; + } + case "LAST_7_DAYS" -> { + start = today.minusDays(6); + end = today; + } + case "LAST_30_DAYS" -> { + start = today.minusDays(29); + end = today; + } + case "THIS_MONTH" -> { + start = today.withDayOfMonth(1); + end = start.plusMonths(1).minusDays(1); + } + case "THIS_YEAR" -> { + start = today.withDayOfYear(1); + end = start.plusYears(1).minusDays(1); + } + case "CUSTOM" -> { + if (request.getTimeRange() == null || isBlank(request.getTimeRange().getStartDate()) + || isBlank(request.getTimeRange().getEndDate())) { + throw new GaoAiToolException("自定义时间范围必须同时提供开始和结束日期", 400); + } + try { + start = LocalDate.parse(request.getTimeRange().getStartDate(), DATE_FORMATTER); + end = LocalDate.parse(request.getTimeRange().getEndDate(), DATE_FORMATTER); + } catch (RuntimeException e) { + throw new GaoAiToolException("日期格式必须为 yyyy-MM-dd", 400); + } + } + default -> throw new GaoAiToolException("不支持的时间范围预设:" + preset, 400); + } + if (start.isAfter(end)) { + throw new GaoAiToolException("时间范围无效", 400); + } + long days = ChronoUnit.DAYS.between(start, end) + 1L; + long maxDays = trend ? 365L : 31L; + if (maxDays < days) { + throw new GaoAiToolException(trend ? "趋势查询最多支持 365 天" : "明细查询最多支持 31 天", 400); + } + long begin = start.atStartOfDay(ZONE).toInstant().toEpochMilli(); + long finish = end.plusDays(1).atStartOfDay(ZONE).toInstant().toEpochMilli(); + return new TimeWindow(start, end, begin, finish); + } + + private int maxPageSize() { + int max = properties.getMaxToolPageSize(); + if (20 < max) { + max = 20; + } + if (max < 1) { + max = 1; + } + return max; + } + + private void setPage(com.imyeyu.java.bean.BasePage page, GaoAiToolRequest request) { + int size = request.getPageSize() == null ? maxPageSize() : request.getPageSize(); + if (size < 1) { + size = 1; + } + if (maxPageSize() < size) { + size = maxPageSize(); + } + page.setSize(size); + page.setIndex(cursorIndex(request.getCursor())); + } + + private int cursorIndex(String cursor) { + if (isBlank(cursor)) { + return 0; + } + try { + int index = Integer.parseInt(cursor); + return index < 0 ? 0 : index; + } catch (NumberFormatException e) { + throw new GaoAiToolException("分页游标无效", 400); + } + } + + private > E enumFilter(GaoAiToolRequest request, String key, Class type) { + String value = filter(request, key); + if (isBlank(value)) { + return null; + } + try { + return Enum.valueOf(type, value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new GaoAiToolException("过滤条件无效:" + key, 400); + } + } + + private Boolean booleanFilter(GaoAiToolRequest request, String key) { + String value = filter(request, key); + return isBlank(value) ? null : Boolean.valueOf(value); + } + + private String filter(GaoAiToolRequest request, String key) { + if (request == null || request.getFilters() == null) { + return null; + } + Object value = request.getFilters().get(key); + return value == null ? null : String.valueOf(value); + } + + private String firstText(String... values) { + for (String value : values) { + if (!isBlank(value)) { + return value.trim(); + } + } + return null; + } + + private boolean containsIgnoreCase(String value, String keyword) { + return value != null && keyword != null && value.toLowerCase(Locale.ROOT).contains(keyword.toLowerCase(Locale.ROOT)); + } + + private boolean containsStoreId(Object value) { + if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + if ("storeId".equals(String.valueOf(entry.getKey())) || containsStoreId(entry.getValue())) { + return true; + } + } + } + if (value instanceof Collection collection) { + for (Object item : collection) { + if (containsStoreId(item)) { + return true; + } + } + } + return false; + } + + private boolean isLowStock(Object value) { + if (value == null) { + return false; + } + Map map = rawMap(value); + Object lowStock = map.get("lowStock"); + return lowStock instanceof Number number && 0 < number.intValue(); + } + + private List sanitizeList(Collection values) { + List result = new ArrayList<>(); + if (values == null) { + return result; + } + for (Object value : values) { + Object sanitized = sanitize(value); + if (sanitized != null) { + result.add(sanitized); + } + } + return result; + } + + private Map sanitizeMap(Map source) { + Map result = new LinkedHashMap<>(); + if (source == null) { + return result; + } + for (Map.Entry entry : source.entrySet()) { + String key = String.valueOf(entry.getKey()); + if (isSensitiveKey(key)) { + continue; + } + Object value = sanitize(entry.getValue()); + if (value != null) { + result.put(key, value); + } + } + return result; + } + + private Object sanitize(Object value) { + if (value == null || value instanceof String || value instanceof Number || value instanceof Boolean || value instanceof Enum) { + return value instanceof Enum enumValue ? enumValue.name() : value; + } + if (value instanceof Map map) { + return sanitizeMap(map); + } + if (value instanceof Collection collection) { + return sanitizeList(collection); + } + try { + return sanitizeMap(jackson.convertValue(value, new TypeReference>() {})); + } catch (IllegalArgumentException e) { + return null; + } + } + + private Map rawMap(Object value) { + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + map.forEach((key, item) -> result.put(String.valueOf(key), item)); + return result; + } + return jackson.convertValue(value, new TypeReference>() {}); + } + + private boolean isSensitiveKey(String key) { + return SENSITIVE_KEYS.contains(key) || key.endsWith("Id") || key.toLowerCase(Locale.ROOT).contains("password") + || key.toLowerCase(Locale.ROOT).contains("token") || key.toLowerCase(Locale.ROOT).contains("url"); + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + private record TimeWindow(LocalDate start, LocalDate endDate, long begin, long end) { + + private int beginDateValue() { + return Integer.parseInt(DAY_VALUE_FORMATTER.format(start)); + } + + private int endDateValue() { + return Integer.parseInt(DAY_VALUE_FORMATTER.format(endDate)); + } + + private String display() { + return "%s 至 %s".formatted(start, endDate); + } + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiSessionServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiSessionServiceImplement.java new file mode 100644 index 0000000..74b7742 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/service/implement/GaoAiSessionServiceImplement.java @@ -0,0 +1,111 @@ +package com.imyeyu.api.modules.gao.ai.service.implement; + +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.mcp.GaoAiToolNames; +import com.imyeyu.api.modules.gao.ai.service.GaoAiSessionService; +import com.imyeyu.spring.util.Redis; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.util.Base64; + +/// GAO 智能体会话服务实现 +/// +/// @author Codex +/// @since 2026-09-15 +@Service +@RequiredArgsConstructor +public class GaoAiSessionServiceImplement implements GaoAiSessionService { + + private static final String KEY_PREFIX = "GAO:AI:SESSION:"; + + private final GaoAiProperties properties; + + @Qualifier("redisGaoAiSession") + private final Redis redis; + + private final SecureRandom secureRandom = new SecureRandom(); + + @Override + public GaoAiSession create(String userId, String storeId, String difyConversationId) { + if (isBlank(userId) || isBlank(storeId)) { + throw new IllegalArgumentException("AI 会话缺少用户或门店"); + } + long ttlSeconds = Math.max(1L, properties.getSessionTtlSeconds()); + GaoAiSession session = new GaoAiSession(); + session.setSessionId(newSessionId()); + session.setUserId(userId); + session.setStoreId(storeId); + session.setDifyConversationId(blankToNull(difyConversationId)); + session.setAllowedTools(GaoAiToolNames.orderedReadOnlyTools()); + session.setExpiresAt(System.currentTimeMillis() + ttlSeconds * 1000L); + session.setUsedCount(0L); + redis.set(key(session.getSessionId()), session, ttlSeconds * 1000L); + return session; + } + + @Override + public GaoAiSession require(String sessionId) { + if (isBlank(sessionId)) { + throw new GaoAiSessionException("缺少 AI 会话", 401); + } + GaoAiSession session = redis.get(key(sessionId.trim())); + if (session == null || session.getExpiresAt() <= System.currentTimeMillis()) { + redis.destroy(key(sessionId.trim())); + throw new GaoAiSessionException("AI 会话不存在或已过期", 401); + } + return session; + } + + @Override + public GaoAiSession increaseUsedCount(String sessionId) { + GaoAiSession session = require(sessionId); + session.setUsedCount(session.getUsedCount() + 1L); + long remain = Math.max(1L, session.getExpiresAt() - System.currentTimeMillis()); + redis.set(key(session.getSessionId()), session, remain); + return session; + } + + @Override + public void delete(String sessionId) { + if (!isBlank(sessionId)) { + redis.destroy(key(sessionId.trim())); + } + } + + private String newSessionId() { + byte[] bytes = new byte[32]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String key(String sessionId) { + return KEY_PREFIX + sessionId; + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + private String blankToNull(String value) { + return isBlank(value) ? null : value.trim(); + } + + /// AI 会话认证异常 + public static class GaoAiSessionException extends RuntimeException { + + private final int status; + + public GaoAiSessionException(String message, int status) { + super(message); + this.status = status; + } + + public int getStatus() { + return status; + } + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiChatRequest.java b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiChatRequest.java new file mode 100644 index 0000000..60c8b78 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiChatRequest.java @@ -0,0 +1,20 @@ +package com.imyeyu.api.modules.gao.ai.vo; + +import lombok.Data; + +/// GAO 智能体聊天请求 +/// +/// @author Codex +/// @since 2026-09-15 +@Data +public class GaoAiChatRequest { + + /// 店长问题 + private String message; + + /// Dify 会话 ID,可为空 + private String conversationId; + + /// GLOBAL_MANAGER 兼任店长时使用当前选中门店 ID,可为空 + private String storeId; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiToolRequest.java b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiToolRequest.java new file mode 100644 index 0000000..a8f6970 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiToolRequest.java @@ -0,0 +1,43 @@ +package com.imyeyu.api.modules.gao.ai.vo; + +import lombok.Data; + +import java.util.LinkedHashMap; +import java.util.Map; + +/// GAO 智能体 MCP 工具通用请求 +/// +/// @author Codex +/// @since 2026-09-15 +@Data +public class GaoAiToolRequest { + + /// 自然语言关键词 + private String query; + + /// 时间范围 + private TimeRange timeRange; + + /// 业务过滤条件,不允许携带 storeId + private Map filters = new LinkedHashMap<>(); + + /// 请求条数,服务端强制限制为 1 至 20 + private Integer pageSize; + + /// 分页游标,当前实现为不透明页码字符串 + private String cursor; + + /// 工具时间范围 + @Data + public static class TimeRange { + + /// 预设范围,例如 TODAY、THIS_MONTH、CUSTOM + private String preset; + + /// 自定义开始日期,格式为 yyyy-MM-dd + private String startDate; + + /// 自定义结束日期,格式为 yyyy-MM-dd,包含当天 + private String endDate; + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiToolResult.java b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiToolResult.java new file mode 100644 index 0000000..52ac6c4 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/ai/vo/GaoAiToolResult.java @@ -0,0 +1,33 @@ +package com.imyeyu.api.modules.gao.ai.vo; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// GAO 智能体 MCP 工具统一返回 +/// +/// @author Codex +/// @since 2026-09-15 +@Data +@NoArgsConstructor +public class GaoAiToolResult { + + /// 数据范围 + private Map scope = new LinkedHashMap<>(); + + /// 聚合摘要 + private Map summary = new LinkedHashMap<>(); + + /// 明细,最多返回 20 条 + private List details = new ArrayList<>(); + + /// 风险和数据提示 + private List alerts = new ArrayList<>(); + + /// 来源和生成时间 + private Map source = new LinkedHashMap<>(); +} 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 70c0884..cc58962 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 @@ -15,6 +15,8 @@ import lombok.Getter; @AllArgsConstructor public enum GaoSystemSetting implements SettingEnum { + DIFY_APP_API_KEY(null, Setting.ValueType.STRING), + SERVICE_LICENSE(null, Setting.ValueType.STRING), PRIVACY_LICENSE(null, Setting.ValueType.STRING), diff --git a/src/main/java/com/imyeyu/api/modules/system/bean/DifySetting.java b/src/main/java/com/imyeyu/api/modules/system/bean/DifySetting.java new file mode 100644 index 0000000..3345706 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/system/bean/DifySetting.java @@ -0,0 +1,28 @@ +package com.imyeyu.api.modules.system.bean; + +import com.imyeyu.api.annotation.SettingBizType; +import com.imyeyu.api.bean.SettingEnum; +import com.imyeyu.api.modules.common.entity.Setting; +import com.imyeyu.api.modules.common.entity.Setting.ValueType; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/// +/// +/// @author 夜雨 +/// @since 2026-09-15 15:39 +@Getter +@SettingBizType("DIFY") +@AllArgsConstructor +public enum DifySetting implements SettingEnum { + + BASE_URL(null, ValueType.STRING), + + API_KEY(null, ValueType.STRING), + + MCP_URL(null, ValueType.STRING); + + final String defaultValue; + + final Setting.ValueType valueType; +}