diff --git a/pom.xml b/pom.xml index 4d1eb2f6..12fb26ce 100644 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 17 17 - 5.8.34 + 5.8.41 9.1.0 1.2.24 @@ -267,6 +267,13 @@ ${dynamic-datasource.version} --> + + + org.springframework.ai + spring-ai-openai-spring-boot-starter + 1.0.0-M6 + + diff --git a/sql/mysql/youlai_boot.sql b/sql/mysql/youlai_boot.sql index aedf2e54..cd5c268a 100644 --- a/sql/mysql/youlai_boot.sql +++ b/sql/mysql/youlai_boot.sql @@ -217,6 +217,8 @@ INSERT INTO `sys_menu` VALUES (148, 89, '0,89', '字典实时同步', 1, 'DictSy INSERT INTO `sys_menu` VALUES (149, 89, '0,89', 'VxeTable', 1, 'VxeTable', 'vxe-table', 'demo/vxe-table/index', NULL, NULL, 1, 1, 0, 'el-icon-MagicStick', '', now(), now(), NULL); INSERT INTO `sys_menu` VALUES (150, 36, '0,36', '自适应表格操作列', 1, 'AutoOperationColumn', 'operation-column', 'demo/auto-operation-column', NULL, NULL, 1, 1, 1, '', '', now(), now(), NULL); INSERT INTO `sys_menu` VALUES (151, 89, '0,89', 'CURD单文件', 1, 'CurdSingle', 'curd-single', 'demo/curd-single', NULL, NULL, 1, 1, 7, 'el-icon-Reading', '', now(),now(), NULL); +INSERT INTO `sys_menu` VALUES (152, 0, '0', 'AI助手', 2, NULL, '/platform', 'Layout', NULL, NULL, NULL, 1, 13, 'platform', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (153, 152, '0,152', 'AI命令记录', 1, 'AiCommandRecord', 'command-record', 'ai/command-record/index', NULL, NULL, 1, 1, 1, 'document', NULL, now(), now(), NULL); -- ---------------------------- @@ -569,4 +571,56 @@ INSERT INTO `sys_user_notice` VALUES (8, 8, 2, 1, NULL, now(), now(), 0); INSERT INTO `sys_user_notice` VALUES (9, 9, 2, 1, NULL, now(), now(), 0); INSERT INTO `sys_user_notice` VALUES (10, 10, 2, 1, NULL, now(), now(), 0); +-- ---------------------------- +-- AI 命令记录表 +-- ---------------------------- +DROP TABLE IF EXISTS `ai_command_record`; +CREATE TABLE `ai_command_record` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `user_id` bigint DEFAULT NULL COMMENT '用户ID', + `username` varchar(64) DEFAULT NULL COMMENT '用户名', + `original_command` text COMMENT '原始命令', + -- 解析相关字段 + `provider` varchar(32) DEFAULT NULL COMMENT 'AI供应商(qwen/openai/deepseek/gemini等)', + `model` varchar(64) DEFAULT NULL COMMENT 'AI模型(qwen-plus/qwen-max/gpt-4-turbo等)', + `parse_success` tinyint(1) DEFAULT NULL COMMENT '解析是否成功(0-失败, 1-成功)', + `function_calls` text COMMENT '解析出的函数调用列表(JSON)', + `explanation` varchar(500) DEFAULT NULL COMMENT 'AI的理解说明', + `confidence` decimal(3,2) DEFAULT NULL COMMENT '置信度(0.00-1.00)', + `parse_error_message` text COMMENT '解析错误信息', + `input_tokens` int DEFAULT NULL COMMENT '输入Token数量', + `output_tokens` int DEFAULT NULL COMMENT '输出Token数量', + `total_tokens` int DEFAULT NULL COMMENT '总Token数量', + `parse_time` bigint DEFAULT NULL COMMENT '解析耗时(毫秒)', + -- 执行相关字段 + `function_name` varchar(255) DEFAULT NULL COMMENT '执行的函数名称', + `function_arguments` text COMMENT '函数参数(JSON)', + `execute_status` varchar(20) DEFAULT NULL COMMENT '执行状态(pending-待执行, success-成功, failed-失败)', + `execute_result` text COMMENT '执行结果(JSON)', + `execute_error_message` text COMMENT '执行错误信息', + `affected_rows` int DEFAULT NULL COMMENT '影响的记录数', + `is_dangerous` tinyint(1) DEFAULT '0' COMMENT '是否危险操作(0-否, 1-是)', + `requires_confirmation` tinyint(1) DEFAULT '0' COMMENT '是否需要确认(0-否, 1-是)', + `user_confirmed` tinyint(1) DEFAULT NULL COMMENT '用户是否确认(0-否, 1-是)', + `idempotency_key` varchar(128) DEFAULT NULL COMMENT '幂等性令牌(防止重复执行)', + `execution_time` bigint DEFAULT NULL COMMENT '执行耗时(毫秒)', + -- 通用字段 + `ip_address` varchar(128) DEFAULT NULL COMMENT 'IP地址', + `user_agent` varchar(512) DEFAULT NULL COMMENT '用户代理', + `current_route` varchar(255) DEFAULT NULL COMMENT '当前页面路由', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_idempotency_key` (`idempotency_key`), + KEY `idx_user_id` (`user_id`), + KEY `idx_create_time` (`create_time`), + KEY `idx_provider` (`provider`), + KEY `idx_model` (`model`), + KEY `idx_parse_success` (`parse_success`), + KEY `idx_execute_status` (`execute_status`), + KEY `idx_is_dangerous` (`is_dangerous`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='AI命令记录表'; + + SET FOREIGN_KEY_CHECKS = 1; diff --git a/src/main/java/com/youlai/boot/YouLaiBootApplication.java b/src/main/java/com/youlai/boot/YouLaiBootApplication.java index 931f48c9..68ece447 100644 --- a/src/main/java/com/youlai/boot/YouLaiBootApplication.java +++ b/src/main/java/com/youlai/boot/YouLaiBootApplication.java @@ -11,7 +11,6 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan; * @since 0.0.1 */ @SpringBootApplication -@ConfigurationPropertiesScan // 开启配置属性绑定 public class YouLaiBootApplication { public static void main(String[] args) { diff --git a/src/main/java/com/youlai/boot/config/WebSocketConfig.java b/src/main/java/com/youlai/boot/config/WebSocketConfig.java index 2168d171..89d4a104 100644 --- a/src/main/java/com/youlai/boot/config/WebSocketConfig.java +++ b/src/main/java/com/youlai/boot/config/WebSocketConfig.java @@ -3,7 +3,7 @@ package com.youlai.boot.config; import cn.hutool.core.util.StrUtil; import com.youlai.boot.security.model.SysUserDetails; import com.youlai.boot.security.token.TokenManager; -import com.youlai.boot.system.service.WebSocketService; +import com.youlai.boot.platform.websocket.service.WebSocketService; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/com/youlai/boot/platform/ai/config/AiProperties.java b/src/main/java/com/youlai/boot/platform/ai/config/AiProperties.java deleted file mode 100644 index 69d19a02..00000000 --- a/src/main/java/com/youlai/boot/platform/ai/config/AiProperties.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.youlai.boot.platform.ai.config; - -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.util.Map; - -/** - * AI 配置属性 - * - * 优势: - * 1. 统一管理所有提供商配置 - * 2. 添加新提供商只需在 yml 中添加配置,无需修改代码 - * 3. 类型安全,支持 IDE 提示 - * - * @author Ray.Hao - */ -@Data -@Component -@ConfigurationProperties(prefix = "ai") -public class AiProperties { - - /** - * 是否启用 AI 功能 - */ - private Boolean enabled = false; - - /** - * 当前使用的提供商(qwen、deepseek、openai 等) - */ - private String provider = "qwen"; - - /** - * 所有提供商的配置 - * Key: 提供商名称(qwen、deepseek、openai) - * Value: 提供商配置 - */ - private Map providers; - - /** - * 安全配置 - */ - private SecurityConfig security = new SecurityConfig(); - - /** - * 限流配置 - */ - private RateLimitConfig rateLimit = new RateLimitConfig(); - - /** - * 提供商配置 - */ - @Data - public static class ProviderConfig { - /** - * API Key - */ - private String apiKey; - - /** - * Base URL(统一命名,符合行业惯例) - */ - private String baseUrl; - - /** - * 模型名称 - */ - private String model; - - /** - * 提供商显示名称(可选) - */ - private String displayName; - - /** - * 超时时间(秒) - */ - private Integer timeout = 30; - } - - /** - * 安全配置 - */ - @Data - public static class SecurityConfig { - private Boolean enableAudit = true; - private Boolean dangerousOperationsConfirm = true; - private java.util.List functionWhitelist; - private java.util.List sensitiveParams; - } - - /** - * 限流配置 - */ - @Data - public static class RateLimitConfig { - private Integer maxExecutionsPerMinute = 10; - private Integer maxExecutionsPerDay = 100; - } - - /** - * 获取当前提供商配置 - */ - public ProviderConfig getCurrentProviderConfig() { - if (providers == null || !providers.containsKey(provider)) { - throw new IllegalStateException("未找到提供商配置: " + provider); - } - return providers.get(provider); - } -} - - diff --git a/src/main/java/com/youlai/boot/platform/ai/config/SpringAiConfig.java b/src/main/java/com/youlai/boot/platform/ai/config/SpringAiConfig.java new file mode 100644 index 00000000..699f3140 --- /dev/null +++ b/src/main/java/com/youlai/boot/platform/ai/config/SpringAiConfig.java @@ -0,0 +1,49 @@ +package com.youlai.boot.platform.ai.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import com.youlai.boot.platform.ai.tools.UserTools; + +/** + * Spring AI 配置类 + * + * 使用 Spring AI 自动配置,支持: + * - OpenAI + * - 通义千问(DashScope 兼容 OpenAI 协议) + * - DeepSeek(兼容 OpenAI 协议) + * - 其他兼容 OpenAI 协议的模型 + * + * 配置方式: + * spring.ai.openai.api-key: xxx + * spring.ai.openai.base-url: xxx + * spring.ai.openai.chat.options.model: xxx + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Slf4j +@Configuration +@ConditionalOnProperty(prefix = "spring.ai.openai.chat", name = "enabled", havingValue = "true", matchIfMissing = false) +public class SpringAiConfig { + + /** + * 创建 ChatClient(Spring AI 核心客户端) + *

+ * OpenAiChatModel 由 Spring AI 自动配置创建 + * 根据 spring.ai.openai.* 配置自动初始化 + */ + @Bean + public ChatClient chatClient(OpenAiChatModel chatModel, UserTools userTools) { + log.info("✅ Spring AI ChatClient 初始化成功"); + log.info("📋 当前配置 - 模型: {}", chatModel.getDefaultOptions().getModel()); + // 将 UserTools 注册为默认工具,所有调用都可使用 + return ChatClient.builder(chatModel) + .defaultTools(userTools) + .build(); + } +} + diff --git a/src/main/java/com/youlai/boot/platform/ai/controller/AiCommandController.java b/src/main/java/com/youlai/boot/platform/ai/controller/AiCommandController.java index 3597fdca..7a11f0b7 100644 --- a/src/main/java/com/youlai/boot/platform/ai/controller/AiCommandController.java +++ b/src/main/java/com/youlai/boot/platform/ai/controller/AiCommandController.java @@ -1,7 +1,14 @@ package com.youlai.boot.platform.ai.controller; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.youlai.boot.core.web.PageResult; import com.youlai.boot.core.web.Result; -import com.youlai.boot.platform.ai.model.dto.*; +import com.youlai.boot.platform.ai.model.dto.AiExecuteRequestDTO; +import com.youlai.boot.platform.ai.model.dto.AiParseRequestDTO; +import com.youlai.boot.platform.ai.model.dto.AiParseResponseDTO; +import com.youlai.boot.platform.ai.model.query.AiCommandPageQuery; +import com.youlai.boot.platform.ai.model.vo.AiCommandRecordVO; +import com.youlai.boot.platform.ai.service.AiCommandRecordService; import com.youlai.boot.platform.ai.service.AiCommandService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -11,9 +18,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; - /** - * AI 命令控制器 + * AI 命令控制器(基于 Spring AI) * * @author Ray.Hao * @since 3.0.0 @@ -25,75 +31,60 @@ import org.springframework.web.bind.annotation.*; @Slf4j public class AiCommandController { - private final AiCommandService aiCommandService; + private final AiCommandService aiCommandService; + private final AiCommandRecordService recordService; - @Operation(summary = "解析自然语言命令") - @PostMapping("/parse") - public Result parseCommand( - @RequestBody AiCommandRequestDTO request, - HttpServletRequest httpRequest - ) { - log.info("收到AI命令解析请求: {}", request.getCommand()); + @Operation(summary = "解析自然语言命令") + @PostMapping("/parse") + public Result parseCommand( + @RequestBody AiParseRequestDTO request, + HttpServletRequest httpRequest + ) { + log.info("收到AI命令解析请求: {}", request.getCommand()); - try { - AiCommandResponseDTO response = aiCommandService.parseCommand(request, httpRequest); - return Result.success(response); - } catch (Exception e) { - log.error("命令解析失败", e); - return Result.success(AiCommandResponseDTO.builder() - .success(false) - .error(e.getMessage()) - .build()); - } + try { + AiParseResponseDTO response = aiCommandService.parseCommand(request, httpRequest); + return Result.success(response); + } catch (Exception e) { + log.error("命令解析失败", e); + return Result.success(AiParseResponseDTO.builder() + .success(false) + .error(e.getMessage()) + .build()); } + } - @Operation(summary = "执行已解析的命令") - @PostMapping("/execute") - public Result executeCommand( - @RequestBody AiExecuteRequestDTO request, - HttpServletRequest httpRequest - ) { - log.info("收到AI命令执行请求: {}", request.getFunctionCall().getName()); - - try { - AiExecuteResponseDTO response = aiCommandService.executeCommand(request, httpRequest); - return Result.success(response); - } catch (Exception e) { - log.error("命令执行失败", e); - return Result.success(AiExecuteResponseDTO.builder() - .success(false) - .error(e.getMessage()) - .build()); - } + @Operation(summary = "执行已解析的命令") + @PostMapping("/execute") + public Result executeCommand( + @RequestBody AiExecuteRequestDTO request, + HttpServletRequest httpRequest + ) { + log.info("收到AI命令执行请求: {}", request.getFunctionCall().getName()); + try { + Object result = aiCommandService.executeCommand(request, httpRequest); + return Result.success(result); + } catch (Exception e) { + log.error("命令执行失败", e); + return Result.failed(e.getMessage()); } + } - @Operation(summary = "获取命令执行历史") - @GetMapping("/history") - public Result getCommandHistory( - @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page, - @Parameter(description = "每页数量") @RequestParam(defaultValue = "10") Integer size - ) { - return Result.success(aiCommandService.getCommandHistory(page, size)); - } + @Operation(summary = "获取AI命令记录分页列表") + @GetMapping("/records") + public PageResult getRecordPage(AiCommandPageQuery queryParams) { + IPage page = recordService.getRecordPage(queryParams); + return PageResult.success(page); + } - @Operation(summary = "获取可用的函数列表") - @GetMapping("/functions") - public Result getAvailableFunctions() { - return Result.success(aiCommandService.getAvailableFunctions()); - } + @Operation(summary = "撤销命令执行") + @PostMapping("/rollback/{recordId}") + public Result rollbackCommand( + @Parameter(description = "记录ID") @PathVariable String recordId + ) { + recordService.rollbackCommand(recordId); + return Result.success("撤销成功"); + } - @Operation(summary = "撤销命令执行") - @PostMapping("/rollback/{auditId}") - public Result rollbackCommand( - @Parameter(description = "审计ID") @PathVariable String auditId - ) { - aiCommandService.rollbackCommand(auditId); - return Result.success("撤销成功"); - } } - - - - - diff --git a/src/main/java/com/youlai/boot/platform/ai/mapper/AiCommandAuditMapper.java b/src/main/java/com/youlai/boot/platform/ai/mapper/AiCommandAuditMapper.java deleted file mode 100644 index c3fe86e5..00000000 --- a/src/main/java/com/youlai/boot/platform/ai/mapper/AiCommandAuditMapper.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.youlai.boot.platform.ai.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.youlai.boot.platform.ai.model.entity.AiCommandAudit; -import org.apache.ibatis.annotations.Mapper; - -/** - * AI 命令审计 Mapper - * - * @author Ray.Hao - * @since 3.0.0 - */ -@Mapper -public interface AiCommandAuditMapper extends BaseMapper { -} - - - - - diff --git a/src/main/java/com/youlai/boot/platform/ai/mapper/AiCommandRecordMapper.java b/src/main/java/com/youlai/boot/platform/ai/mapper/AiCommandRecordMapper.java new file mode 100644 index 00000000..40e16903 --- /dev/null +++ b/src/main/java/com/youlai/boot/platform/ai/mapper/AiCommandRecordMapper.java @@ -0,0 +1,23 @@ +package com.youlai.boot.platform.ai.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.platform.ai.model.entity.AiCommandRecord; +import com.youlai.boot.platform.ai.model.query.AiCommandPageQuery; +import com.youlai.boot.platform.ai.model.vo.AiCommandRecordVO; +import org.apache.ibatis.annotations.Mapper; + +/** + * AI 命令记录 Mapper + */ +@Mapper +public interface AiCommandRecordMapper extends BaseMapper { + + /** + * 获取 AI 命令记录分页列表 + */ + IPage getRecordPage(Page page, AiCommandPageQuery queryParams); +} + + diff --git a/src/main/java/com/youlai/boot/platform/ai/model/dto/AiExecuteRequestDTO.java b/src/main/java/com/youlai/boot/platform/ai/model/dto/AiExecuteRequestDTO.java index f091d99b..0dc54317 100644 --- a/src/main/java/com/youlai/boot/platform/ai/model/dto/AiExecuteRequestDTO.java +++ b/src/main/java/com/youlai/boot/platform/ai/model/dto/AiExecuteRequestDTO.java @@ -11,10 +11,20 @@ import lombok.Data; @Data public class AiExecuteRequestDTO { + /** + * 关联的解析日志ID + */ + private String parseLogId; + + /** + * 原始命令(用于审计) + */ + private String originalCommand; + /** * 要执行的函数调用 */ - private FunctionCallDTO functionCall; + private AiFunctionCallDTO functionCall; /** * 确认模式:auto=自动执行, manual=需要用户确认 @@ -30,6 +40,11 @@ public class AiExecuteRequestDTO { * 幂等性令牌(防止重复执行) */ private String idempotencyKey; + + /** + * 当前页面路由 + */ + private String currentRoute; } diff --git a/src/main/java/com/youlai/boot/platform/ai/model/dto/AiExecuteResponseDTO.java b/src/main/java/com/youlai/boot/platform/ai/model/dto/AiExecuteResponseDTO.java index 279126d6..8796e58e 100644 --- a/src/main/java/com/youlai/boot/platform/ai/model/dto/AiExecuteResponseDTO.java +++ b/src/main/java/com/youlai/boot/platform/ai/model/dto/AiExecuteResponseDTO.java @@ -43,9 +43,9 @@ public class AiExecuteResponseDTO { private String error; /** - * 审计ID(用于追踪) + * 记录ID(用于追踪) */ - private String auditId; + private Long recordId; /** * 需要用户确认 diff --git a/src/main/java/com/youlai/boot/platform/ai/model/dto/FunctionCallDTO.java b/src/main/java/com/youlai/boot/platform/ai/model/dto/AiFunctionCallDTO.java similarity index 89% rename from src/main/java/com/youlai/boot/platform/ai/model/dto/FunctionCallDTO.java rename to src/main/java/com/youlai/boot/platform/ai/model/dto/AiFunctionCallDTO.java index 8e08eb2b..2b2368b1 100644 --- a/src/main/java/com/youlai/boot/platform/ai/model/dto/FunctionCallDTO.java +++ b/src/main/java/com/youlai/boot/platform/ai/model/dto/AiFunctionCallDTO.java @@ -7,7 +7,7 @@ import lombok.NoArgsConstructor; import java.util.Map; /** - * 函数调用 DTO + * AI 函数调用 DTO * * @author Ray.Hao * @since 3.0.0 @@ -16,7 +16,7 @@ import java.util.Map; @Builder @NoArgsConstructor @AllArgsConstructor -public class FunctionCallDTO { +public class AiFunctionCallDTO { /** * 函数名称 @@ -34,5 +34,3 @@ public class FunctionCallDTO { private Map arguments; } - - diff --git a/src/main/java/com/youlai/boot/platform/ai/model/dto/AiCommandRequestDTO.java b/src/main/java/com/youlai/boot/platform/ai/model/dto/AiParseRequestDTO.java similarity index 89% rename from src/main/java/com/youlai/boot/platform/ai/model/dto/AiCommandRequestDTO.java rename to src/main/java/com/youlai/boot/platform/ai/model/dto/AiParseRequestDTO.java index 33b8a811..4578a28a 100644 --- a/src/main/java/com/youlai/boot/platform/ai/model/dto/AiCommandRequestDTO.java +++ b/src/main/java/com/youlai/boot/platform/ai/model/dto/AiParseRequestDTO.java @@ -4,13 +4,13 @@ import lombok.Data; import java.util.Map; /** - * AI 命令请求 DTO + * AI 解析请求 DTO * * @author Ray.Hao * @since 3.0.0 */ @Data -public class AiCommandRequestDTO { +public class AiParseRequestDTO { /** * 用户输入的自然语言命令 @@ -33,5 +33,3 @@ public class AiCommandRequestDTO { private Map context; } - - diff --git a/src/main/java/com/youlai/boot/platform/ai/model/dto/AiCommandResponseDTO.java b/src/main/java/com/youlai/boot/platform/ai/model/dto/AiParseResponseDTO.java similarity index 77% rename from src/main/java/com/youlai/boot/platform/ai/model/dto/AiCommandResponseDTO.java rename to src/main/java/com/youlai/boot/platform/ai/model/dto/AiParseResponseDTO.java index 4331b05b..239dacc5 100644 --- a/src/main/java/com/youlai/boot/platform/ai/model/dto/AiCommandResponseDTO.java +++ b/src/main/java/com/youlai/boot/platform/ai/model/dto/AiParseResponseDTO.java @@ -7,7 +7,7 @@ import lombok.NoArgsConstructor; import java.util.List; /** - * AI 命令解析响应 DTO + * AI 解析响应 DTO * * @author Ray.Hao * @since 3.0.0 @@ -16,7 +16,12 @@ import java.util.List; @Builder @NoArgsConstructor @AllArgsConstructor -public class AiCommandResponseDTO { +public class AiParseResponseDTO { + + /** + * 解析日志ID(用于关联执行记录) + */ + private Long parseLogId; /** * 是否成功解析 @@ -26,7 +31,7 @@ public class AiCommandResponseDTO { /** * 解析后的函数调用列表 */ - private List functionCalls; + private List functionCalls; /** * AI 的理解和说明 @@ -49,5 +54,3 @@ public class AiCommandResponseDTO { private String rawResponse; } - - diff --git a/src/main/java/com/youlai/boot/platform/ai/model/entity/AiCommandAudit.java b/src/main/java/com/youlai/boot/platform/ai/model/entity/AiCommandAudit.java deleted file mode 100644 index 9746f349..00000000 --- a/src/main/java/com/youlai/boot/platform/ai/model/entity/AiCommandAudit.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.youlai.boot.platform.ai.model.entity; - -import com.baomidou.mybatisplus.annotation.*; -import lombok.Data; -import java.time.LocalDateTime; - -/** - * AI 命令审计记录 - * - * @author Ray.Hao - * @since 3.0.0 - */ -@Data -@TableName("ai_command_audit") -public class AiCommandAudit { - - /** - * 主键ID - */ - @TableId(type = IdType.ASSIGN_UUID) - private String id; - - /** - * 用户ID - */ - private Long userId; - - /** - * 用户名 - */ - private String username; - - /** - * 原始命令 - */ - private String originalCommand; - - /** - * 解析后的函数名称 - */ - private String functionName; - - /** - * 函数参数(JSON) - */ - private String functionArguments; - - /** - * 执行状态:pending, success, failed - */ - private String executeStatus; - - /** - * 执行结果(JSON) - */ - private String executeResult; - - /** - * 错误信息 - */ - private String errorMessage; - - /** - * 影响的记录数 - */ - private Integer affectedRows; - - /** - * 是否危险操作 - */ - private Boolean isDangerous; - - /** - * 是否需要确认 - */ - private Boolean requiresConfirmation; - - /** - * 用户是否确认 - */ - private Boolean userConfirmed; - - /** - * 幂等性令牌 - */ - private String idempotencyKey; - - /** - * IP 地址 - */ - private String ipAddress; - - /** - * 用户代理 - */ - private String userAgent; - - /** - * 当前路由 - */ - private String currentRoute; - - /** - * 创建时间 - */ - @TableField(fill = FieldFill.INSERT) - private LocalDateTime createTime; - - /** - * 执行时间(毫秒) - */ - private Long executionTime; - - /** - * 备注 - */ - private String remark; -} - - - diff --git a/src/main/java/com/youlai/boot/platform/ai/model/entity/AiCommandRecord.java b/src/main/java/com/youlai/boot/platform/ai/model/entity/AiCommandRecord.java new file mode 100644 index 00000000..8fed3d44 --- /dev/null +++ b/src/main/java/com/youlai/boot/platform/ai/model/entity/AiCommandRecord.java @@ -0,0 +1,115 @@ +package com.youlai.boot.platform.ai.model.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.youlai.boot.common.base.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; + +/** + * AI 命令记录实体(合并解析和执行记录) + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("ai_command_record") +public class AiCommandRecord extends BaseEntity { + + /** 用户ID */ + private Long userId; + + /** 用户名 */ + private String username; + + /** 原始命令 */ + private String originalCommand; + + // ==================== 解析相关字段 ==================== + + /** AI 供应商(qwen/openai/deepseek等) */ + private String provider; + + /** AI 模型(qwen-plus/qwen-max/gpt-4-turbo等) */ + private String model; + + /** 解析是否成功 */ + private Boolean parseSuccess; + + /** 解析出的函数调用列表(JSON) */ + private String functionCalls; + + /** AI 的理解说明 */ + private String explanation; + + /** 置信度(0.00-1.00) */ + private BigDecimal confidence; + + /** 解析错误信息 */ + private String parseErrorMessage; + + /** 输入 Token 数量 */ + private Integer inputTokens; + + /** 输出 Token 数量 */ + private Integer outputTokens; + + /** 总 Token 数量 */ + private Integer totalTokens; + + /** 解析耗时(毫秒) */ + private Long parseTime; + + // ==================== 执行相关字段 ==================== + + /** 执行的函数名称 */ + private String functionName; + + /** 函数参数(JSON) */ + private String functionArguments; + + /** 执行状态:pending, success, failed */ + private String executeStatus; + + /** 执行结果(JSON) */ + private String executeResult; + + /** 执行错误信息 */ + private String executeErrorMessage; + + /** 影响的记录数 */ + private Integer affectedRows; + + /** 是否危险操作 */ + private Boolean isDangerous; + + /** 是否需要确认 */ + private Boolean requiresConfirmation; + + /** 用户是否确认 */ + private Boolean userConfirmed; + + /** 幂等性令牌(防止重复执行) */ + private String idempotencyKey; + + /** 执行耗时(毫秒) */ + private Long executionTime; + + // ==================== 通用字段 ==================== + + /** IP 地址 */ + private String ipAddress; + + /** 用户代理 */ + private String userAgent; + + /** 当前页面路由 */ + private String currentRoute; + + /** 备注 */ + private String remark; +} + + diff --git a/src/main/java/com/youlai/boot/platform/ai/model/query/AiCommandPageQuery.java b/src/main/java/com/youlai/boot/platform/ai/model/query/AiCommandPageQuery.java new file mode 100644 index 00000000..279b915d --- /dev/null +++ b/src/main/java/com/youlai/boot/platform/ai/model/query/AiCommandPageQuery.java @@ -0,0 +1,39 @@ +package com.youlai.boot.platform.ai.model.query; + +import com.youlai.boot.common.base.BasePageQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +/** + * AI命令记录分页查询对象 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Schema(description = "AI命令记录分页查询对象") +@Getter +@Setter +public class AiCommandPageQuery extends BasePageQuery { + + @Schema(description = "关键字(原始命令/函数名称/用户名)") + private String keywords; + + @Schema(description = "执行状态(pending-待执行, success-成功, failed-失败)") + private String executeStatus; + + @Schema(description = "用户ID") + private Long userId; + + @Schema(description = "是否危险操作") + private Boolean isDangerous; + + @Schema(description = "创建时间范围") + private List createTime; + + @Schema(description = "函数名称") + private String functionName; +} + diff --git a/src/main/java/com/youlai/boot/platform/ai/model/query/AiParseLogPageQuery.java b/src/main/java/com/youlai/boot/platform/ai/model/query/AiParseLogPageQuery.java new file mode 100644 index 00000000..065de26e --- /dev/null +++ b/src/main/java/com/youlai/boot/platform/ai/model/query/AiParseLogPageQuery.java @@ -0,0 +1,39 @@ +package com.youlai.boot.platform.ai.model.query; + +import com.youlai.boot.common.base.BasePageQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +/** + * AI命令解析日志分页查询对象 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Schema(description = "AI命令解析日志分页查询对象") +@Getter +@Setter +public class AiParseLogPageQuery extends BasePageQuery { + + @Schema(description = "关键字(原始命令/用户名)") + private String keywords; + + @Schema(description = "解析是否成功(0-失败, 1-成功)") + private Boolean parseSuccess; + + @Schema(description = "用户ID") + private Long userId; + + @Schema(description = "AI提供商(qwen/openai/deepseek/gemini等)") + private String provider; + + @Schema(description = "AI模型(qwen-plus/qwen-max/gpt-4-turbo等)") + private String model; + + @Schema(description = "创建时间范围") + private List createTime; +} + diff --git a/src/main/java/com/youlai/boot/platform/ai/model/vo/AiCommandRecordVO.java b/src/main/java/com/youlai/boot/platform/ai/model/vo/AiCommandRecordVO.java new file mode 100644 index 00000000..83df97ff --- /dev/null +++ b/src/main/java/com/youlai/boot/platform/ai/model/vo/AiCommandRecordVO.java @@ -0,0 +1,120 @@ +package com.youlai.boot.platform.ai.model.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * AI命令记录VO(合并解析和执行记录) + */ +@Data +@Schema(description = "AI命令记录VO") +public class AiCommandRecordVO implements Serializable { + + @Schema(description = "主键ID") + private String id; + + @Schema(description = "用户ID") + private Long userId; + + @Schema(description = "用户名") + private String username; + + @Schema(description = "原始命令") + private String originalCommand; + + // ==================== 解析相关字段 ==================== + + @Schema(description = "AI供应商") + private String provider; + + @Schema(description = "AI模型") + private String model; + + @Schema(description = "解析是否成功") + private Boolean parseSuccess; + + @Schema(description = "解析出的函数调用列表(JSON)") + private String functionCalls; + + @Schema(description = "AI的理解说明") + private String explanation; + + @Schema(description = "置信度") + private BigDecimal confidence; + + @Schema(description = "解析错误信息") + private String parseErrorMessage; + + @Schema(description = "输入Token数量") + private Integer inputTokens; + + @Schema(description = "输出Token数量") + private Integer outputTokens; + + @Schema(description = "总Token数量") + private Integer totalTokens; + + @Schema(description = "解析耗时(毫秒)") + private Long parseTime; + + // ==================== 执行相关字段 ==================== + + @Schema(description = "执行的函数名称") + private String functionName; + + @Schema(description = "函数参数(JSON)") + private String functionArguments; + + @Schema(description = "执行状态") + private String executeStatus; + + @Schema(description = "执行结果(JSON)") + private String executeResult; + + @Schema(description = "执行错误信息") + private String executeErrorMessage; + + @Schema(description = "影响的记录数") + private Integer affectedRows; + + @Schema(description = "是否危险操作") + private Boolean isDangerous; + + @Schema(description = "是否需要确认") + private Boolean requiresConfirmation; + + @Schema(description = "用户是否确认") + private Boolean userConfirmed; + + @Schema(description = "执行耗时(毫秒)") + private Long executionTime; + + // ==================== 通用字段 ==================== + + @Schema(description = "IP地址") + private String ipAddress; + + @Schema(description = "用户代理") + private String userAgent; + + @Schema(description = "当前页面路由") + private String currentRoute; + + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + @Schema(description = "更新时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; + + @Schema(description = "备注") + private String remark; +} + + diff --git a/src/main/java/com/youlai/boot/platform/ai/provider/AbstractOpenAiCompatibleProvider.java b/src/main/java/com/youlai/boot/platform/ai/provider/AbstractOpenAiCompatibleProvider.java deleted file mode 100644 index bf7ca322..00000000 --- a/src/main/java/com/youlai/boot/platform/ai/provider/AbstractOpenAiCompatibleProvider.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.youlai.boot.platform.ai.provider; - -import cn.hutool.core.util.StrUtil; -import cn.hutool.http.HttpRequest; -import cn.hutool.http.HttpResponse; -import cn.hutool.json.JSONObject; -import cn.hutool.json.JSONUtil; -import com.youlai.boot.platform.ai.config.AiProperties; -import lombok.extern.slf4j.Slf4j; - -import java.util.concurrent.TimeUnit; - -/** - * OpenAI 兼容协议的抽象提供商 - * - * 适用于:通义千问、DeepSeek、OpenAI、ChatGLM 等兼容 OpenAI API 的模型 - * - * @author Ray.Hao - */ -@Slf4j -public abstract class AbstractOpenAiCompatibleProvider implements AiProvider { - - protected final AiProperties.ProviderConfig config; - - public AbstractOpenAiCompatibleProvider(AiProperties.ProviderConfig config) { - this.config = config; - } - - @Override - public String call(String systemPrompt, String userPrompt) { - if (!isConfigValid()) { - throw new IllegalStateException(getProviderName() + " 配置无效"); - } - - try { - // 构建请求体(OpenAI 标准格式) - JSONObject requestBody = JSONUtil.createObj() - .set("model", config.getModel()) - .set("messages", JSONUtil.createArray() - .put(JSONUtil.createObj() - .set("role", "system") - .set("content", systemPrompt)) - .put(JSONUtil.createObj() - .set("role", "user") - .set("content", userPrompt)) - ) - .set("temperature", 0.7); - - log.info("📤 调用 {} API: {}/chat/completions", getProviderName(), config.getBaseUrl()); - log.debug("请求参数: {}", requestBody); - - // 发送 HTTP 请求 - HttpResponse response = HttpRequest.post(config.getBaseUrl() + "/chat/completions") - .header("Authorization", "Bearer " + config.getApiKey()) - .header("Content-Type", "application/json") - .body(requestBody.toString()) - .timeout((int) TimeUnit.SECONDS.toMillis(config.getTimeout())) - .execute(); - - // 检查响应状态 - if (!response.isOk()) { - String errorMsg = String.format("%s API 调用失败: HTTP %d - %s", - getProviderName(), response.getStatus(), response.body()); - log.error(errorMsg); - throw new RuntimeException(errorMsg); - } - - // 解析响应 - JSONObject responseJson = JSONUtil.parseObj(response.body()); - String content = responseJson.getByPath("choices[0].message.content", String.class); - - // 记录 Token 使用情况 - JSONObject usage = responseJson.getJSONObject("usage"); - if (usage != null) { - Integer inputTokens = usage.getInt("prompt_tokens"); - Integer outputTokens = usage.getInt("completion_tokens"); - Integer totalTokens = usage.getInt("total_tokens"); - log.info("✅ {} 响应成功,tokens: 输入={}, 输出={}, 总计={}", - getProviderName(), inputTokens, outputTokens, totalTokens); - } - - log.debug("📥 {} 返回内容: {}", getProviderName(), content); - return content; - - } catch (Exception e) { - String errorMsg = String.format("%s API 调用失败: %s", getProviderName(), e.getMessage()); - log.error(errorMsg, e); - throw new RuntimeException(errorMsg, e); - } - } - - @Override - public boolean isConfigValid() { - return config != null - && StrUtil.isNotBlank(config.getApiKey()) - && StrUtil.isNotBlank(config.getBaseUrl()) - && StrUtil.isNotBlank(config.getModel()); - } -} - - diff --git a/src/main/java/com/youlai/boot/platform/ai/provider/AiProvider.java b/src/main/java/com/youlai/boot/platform/ai/provider/AiProvider.java deleted file mode 100644 index d6c23301..00000000 --- a/src/main/java/com/youlai/boot/platform/ai/provider/AiProvider.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.youlai.boot.platform.ai.provider; - -/** - * AI 提供商接口 - * - * 策略模式:不同提供商实现各自的调用逻辑 - * - * @author Ray.Hao - */ -public interface AiProvider { - - /** - * 调用 AI API - * - * @param systemPrompt 系统提示词 - * @param userPrompt 用户提示词 - * @return AI 响应内容 - */ - String call(String systemPrompt, String userPrompt); - - /** - * 获取提供商名称 - */ - String getProviderName(); - - /** - * 检查配置是否有效 - */ - boolean isConfigValid(); -} - - diff --git a/src/main/java/com/youlai/boot/platform/ai/provider/AiProviderFactory.java b/src/main/java/com/youlai/boot/platform/ai/provider/AiProviderFactory.java deleted file mode 100644 index 16b32254..00000000 --- a/src/main/java/com/youlai/boot/platform/ai/provider/AiProviderFactory.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.youlai.boot.platform.ai.provider; - -import com.youlai.boot.platform.ai.config.AiProperties; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Map; - -/** - * AI 提供商工厂 - * - * 职责:根据配置获取对应的提供商实例 - * - * @author Ray.Hao - */ -@Component -@RequiredArgsConstructor -public class AiProviderFactory { - - private final AiProperties aiProperties; - - /** - * Spring 自动注入所有 AiProvider 实现类 - * Key: Bean 名称(qwen、deepseek、openai) - * Value: 提供商实例 - */ - private final Map providers; - - /** - * 获取当前配置的提供商 - */ - public AiProvider getCurrentProvider() { - String providerName = aiProperties.getProvider(); - - if (!providers.containsKey(providerName)) { - throw new IllegalStateException("不支持的 AI 提供商: " + providerName - + ",可用提供商: " + providers.keySet()); - } - - AiProvider provider = providers.get(providerName); - - if (!provider.isConfigValid()) { - throw new IllegalStateException(provider.getProviderName() - + " 配置无效,请检查 API Key、Base URL 和 Model 是否配置"); - } - - return provider; - } -} - - diff --git a/src/main/java/com/youlai/boot/platform/ai/provider/impl/DeepSeekProvider.java b/src/main/java/com/youlai/boot/platform/ai/provider/impl/DeepSeekProvider.java deleted file mode 100644 index 12b80aaf..00000000 --- a/src/main/java/com/youlai/boot/platform/ai/provider/impl/DeepSeekProvider.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.youlai.boot.platform.ai.provider.impl; - -import com.youlai.boot.platform.ai.config.AiProperties; -import com.youlai.boot.platform.ai.provider.AbstractOpenAiCompatibleProvider; -import org.springframework.stereotype.Component; - -/** - * DeepSeek 提供商 - * - * @author Ray.Hao - */ -@Component("deepseek") -public class DeepSeekProvider extends AbstractOpenAiCompatibleProvider { - - public DeepSeekProvider(AiProperties aiProperties) { - super(aiProperties.getProviders().get("deepseek")); - } - - @Override - public String getProviderName() { - return config.getDisplayName() != null ? config.getDisplayName() : "DeepSeek"; - } -} - - diff --git a/src/main/java/com/youlai/boot/platform/ai/provider/impl/OpenAiProvider.java b/src/main/java/com/youlai/boot/platform/ai/provider/impl/OpenAiProvider.java deleted file mode 100644 index 09613e33..00000000 --- a/src/main/java/com/youlai/boot/platform/ai/provider/impl/OpenAiProvider.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.youlai.boot.platform.ai.provider.impl; - -import com.youlai.boot.platform.ai.config.AiProperties; -import com.youlai.boot.platform.ai.provider.AbstractOpenAiCompatibleProvider; -import org.springframework.stereotype.Component; - -/** - * OpenAI 提供商(GPT-4、GPT-3.5 等) - * - * 添加新提供商只需: - * 1. 继承 AbstractOpenAiCompatibleProvider - * 2. 实现 getProviderName() - * 3. 在配置文件中添加配置 - * - * @author Ray.Hao - */ -@Component("openai") -public class OpenAiProvider extends AbstractOpenAiCompatibleProvider { - - public OpenAiProvider(AiProperties aiProperties) { - super(aiProperties.getProviders().get("openai")); - } - - @Override - public String getProviderName() { - return config.getDisplayName() != null ? config.getDisplayName() : "OpenAI"; - } -} - - diff --git a/src/main/java/com/youlai/boot/platform/ai/provider/impl/QwenProvider.java b/src/main/java/com/youlai/boot/platform/ai/provider/impl/QwenProvider.java deleted file mode 100644 index 5a6b6dcd..00000000 --- a/src/main/java/com/youlai/boot/platform/ai/provider/impl/QwenProvider.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.youlai.boot.platform.ai.provider.impl; - -import com.youlai.boot.platform.ai.config.AiProperties; -import com.youlai.boot.platform.ai.provider.AbstractOpenAiCompatibleProvider; -import org.springframework.stereotype.Component; - -/** - * 阿里通义千问提供商 - * - * @author Ray.Hao - */ -@Component("qwen") -public class QwenProvider extends AbstractOpenAiCompatibleProvider { - - public QwenProvider(AiProperties aiProperties) { - super(aiProperties.getProviders().get("qwen")); - } - - @Override - public String getProviderName() { - return config.getDisplayName() != null ? config.getDisplayName() : "阿里通义千问"; - } -} - - diff --git a/src/main/java/com/youlai/boot/platform/ai/service/AiCommandRecordService.java b/src/main/java/com/youlai/boot/platform/ai/service/AiCommandRecordService.java new file mode 100644 index 00000000..a4d9543a --- /dev/null +++ b/src/main/java/com/youlai/boot/platform/ai/service/AiCommandRecordService.java @@ -0,0 +1,30 @@ +package com.youlai.boot.platform.ai.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.platform.ai.model.entity.AiCommandRecord; +import com.youlai.boot.platform.ai.model.query.AiCommandPageQuery; +import com.youlai.boot.platform.ai.model.vo.AiCommandRecordVO; + +/** + * AI 命令记录服务接口 + */ +public interface AiCommandRecordService extends IService { + + /** + * 获取命令记录分页列表 + * + * @param queryParams 查询参数 + * @return 命令记录分页列表 + */ + IPage getRecordPage(AiCommandPageQuery queryParams); + + /** + * 撤销命令执行 + * + * @param recordId 记录ID + */ + void rollbackCommand(String recordId); +} + + diff --git a/src/main/java/com/youlai/boot/platform/ai/service/AiCommandService.java b/src/main/java/com/youlai/boot/platform/ai/service/AiCommandService.java index f6c5b0a8..28d972ba 100644 --- a/src/main/java/com/youlai/boot/platform/ai/service/AiCommandService.java +++ b/src/main/java/com/youlai/boot/platform/ai/service/AiCommandService.java @@ -1,63 +1,29 @@ package com.youlai.boot.platform.ai.service; -import com.youlai.boot.platform.ai.model.dto.*; +import com.youlai.boot.platform.ai.model.dto.AiExecuteRequestDTO; +import com.youlai.boot.platform.ai.model.dto.AiParseRequestDTO; +import com.youlai.boot.platform.ai.model.dto.AiParseResponseDTO; import jakarta.servlet.http.HttpServletRequest; -import java.util.List; -import java.util.Map; - /** - * AI 命令服务接口 - * - * @author Ray.Hao - * @since 3.0.0 + * AI 命令编排服务:负责对外的解析与执行编排 */ public interface AiCommandService { - /** - * 解析自然语言命令 - * - * @param request 命令请求 - * @param httpRequest HTTP 请求 - * @return 解析结果 - */ - AiCommandResponseDTO parseCommand(AiCommandRequestDTO request, HttpServletRequest httpRequest); + /** + * 解析自然语言命令 + */ + AiParseResponseDTO parseCommand(AiParseRequestDTO request, HttpServletRequest httpRequest); - /** - * 执行已解析的命令 - * - * @param request 执行请求 - * @param httpRequest HTTP 请求 - * @return 执行结果 - */ - AiExecuteResponseDTO executeCommand(AiExecuteRequestDTO request, HttpServletRequest httpRequest); - - /** - * 获取命令执行历史 - * - * @param page 页码 - * @param size 每页数量 - * @return 历史记录 - */ - Map getCommandHistory(Integer page, Integer size); - - /** - * 获取可用的函数列表 - * - * @return 函数列表 - */ - List> getAvailableFunctions(); - - /** - * 撤销命令执行 - * - * @param auditId 审计ID - */ - void rollbackCommand(String auditId); + /** + * 执行已解析的命令 + * + * @param request 执行请求 + * @param httpRequest HTTP 请求 + * @return 执行结果数据(成功时返回) + * @throws Exception 执行失败时抛出异常 + */ + Object executeCommand(AiExecuteRequestDTO request, HttpServletRequest httpRequest) throws Exception; } - - - - diff --git a/src/main/java/com/youlai/boot/platform/ai/service/impl/AiCommandRecordServiceImpl.java b/src/main/java/com/youlai/boot/platform/ai/service/impl/AiCommandRecordServiceImpl.java new file mode 100644 index 00000000..1a0b87a8 --- /dev/null +++ b/src/main/java/com/youlai/boot/platform/ai/service/impl/AiCommandRecordServiceImpl.java @@ -0,0 +1,47 @@ +package com.youlai.boot.platform.ai.service.impl; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.platform.ai.mapper.AiCommandRecordMapper; +import com.youlai.boot.platform.ai.model.entity.AiCommandRecord; +import com.youlai.boot.platform.ai.model.query.AiCommandPageQuery; +import com.youlai.boot.platform.ai.model.vo.AiCommandRecordVO; +import com.youlai.boot.platform.ai.service.AiCommandRecordService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * AI 命令记录服务实现类 + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class AiCommandRecordServiceImpl extends ServiceImpl + implements AiCommandRecordService { + + @Override + public IPage getRecordPage(AiCommandPageQuery queryParams) { + Page page = new Page<>(queryParams.getPageNum(), queryParams.getPageSize()); + return this.baseMapper.getRecordPage(page, queryParams); + } + + @Override + public void rollbackCommand(String recordId) { + AiCommandRecord record = this.getById(recordId); + if (record == null) { + throw new RuntimeException("命令记录不存在"); + } + + if (!"success".equals(record.getExecuteStatus())) { + throw new RuntimeException("只能撤销成功执行的命令"); + } + + // TODO: 实现具体的回滚逻辑 + log.info("撤销命令执行: recordId={}, function={}", recordId, record.getFunctionName()); + throw new UnsupportedOperationException("回滚功能尚未实现"); + } +} + + diff --git a/src/main/java/com/youlai/boot/platform/ai/service/impl/AiCommandServiceImpl.java b/src/main/java/com/youlai/boot/platform/ai/service/impl/AiCommandServiceImpl.java index 346995b2..72e8eb23 100644 --- a/src/main/java/com/youlai/boot/platform/ai/service/impl/AiCommandServiceImpl.java +++ b/src/main/java/com/youlai/boot/platform/ai/service/impl/AiCommandServiceImpl.java @@ -1,262 +1,364 @@ package com.youlai.boot.platform.ai.service.impl; +import cn.hutool.core.lang.TypeReference; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.servlet.JakartaServletUtil; +import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONArray; import cn.hutool.json.JSONObject; -import cn.hutool.json.JSONUtil; -import com.youlai.boot.platform.ai.config.AiProperties; -import com.youlai.boot.platform.ai.model.dto.*; -import com.youlai.boot.platform.ai.model.entity.AiCommandAudit; -import com.youlai.boot.platform.ai.provider.AiProvider; -import com.youlai.boot.platform.ai.provider.AiProviderFactory; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.youlai.boot.platform.ai.model.dto.AiExecuteRequestDTO; +import com.youlai.boot.platform.ai.model.dto.AiFunctionCallDTO; +import com.youlai.boot.platform.ai.model.dto.AiParseRequestDTO; +import com.youlai.boot.platform.ai.model.dto.AiParseResponseDTO; +import com.youlai.boot.platform.ai.model.entity.AiCommandRecord; +import com.youlai.boot.platform.ai.service.AiCommandRecordService; import com.youlai.boot.platform.ai.service.AiCommandService; +import com.youlai.boot.platform.ai.tools.UserTools; +import com.youlai.boot.security.util.SecurityUtils; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatResponse; import org.springframework.stereotype.Service; -import java.util.*; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; /** - * AI 命令服务实现类(重构版) - * - * 重构改进: - * 1. ✅ 使用策略模式 + 工厂模式管理提供商,消除 switch-case - * 2. ✅ 配置映射化,添加新提供商只需配置,无需修改代码 - * 3. ✅ 统一命名为 base-url,符合行业惯例 - * 4. ✅ Service 层直接返回 DTO,不包装 Result(由 Controller 统一处理) - * 5. ✅ 职责清晰,扩展性强 - * - * @author Ray.Hao - * @since 3.0.0 + * AI 命令编排服务实现 */ @Service @Slf4j @RequiredArgsConstructor public class AiCommandServiceImpl implements AiCommandService { - private final AiProperties aiProperties; - private final AiProviderFactory providerFactory; + private static final String SYSTEM_PROMPT = """ + 你是一个智能的企业操作助手,需要将用户的自然语言命令解析成标准的函数调用。 + 请返回严格的 JSON 格式,包含字段: + - success: boolean + - explanation: string + - confidence: number (0-1) + - error: string + - provider: string + - model: string + - functionCalls: 数组,每个元素包含 name、description、arguments(对象) + 当无法识别命令时,success=false,并给出 error。 + """; - // 审计日志存储(简化实现,实际应使用数据库) - private final Map auditStore = new HashMap<>(); + private final AiCommandRecordService recordService; + private final UserTools userTools; + private final ChatClient chatClient; - /** - * 解析自然语言命令 - * - * 注意:直接返回 DTO,不包装 Result - * Controller 负责统一包装成 Result - */ - @Override - public AiCommandResponseDTO parseCommand(AiCommandRequestDTO request, HttpServletRequest httpRequest) { - // 检查 AI 功能是否启用 - if (!aiProperties.getEnabled()) { - throw new IllegalStateException("AI 功能未启用,请在配置文件中设置 ai.enabled=true"); + @Override + public AiParseResponseDTO parseCommand(AiParseRequestDTO request, HttpServletRequest httpRequest) { + long startTime = System.currentTimeMillis(); + String command = Optional.ofNullable(request.getCommand()).orElse("").trim(); + + if (StrUtil.isBlank(command)) { + return AiParseResponseDTO.builder() + .success(false) + .error("命令不能为空") + .functionCalls(Collections.emptyList()) + .build(); + } + + Long userId = SecurityUtils.getUserId(); + String username = SecurityUtils.getUsername(); + String ipAddress = JakartaServletUtil.getClientIP(httpRequest); + + AiCommandRecord record = new AiCommandRecord(); + record.setUserId(userId); + record.setUsername(username); + record.setOriginalCommand(command); + record.setIpAddress(ipAddress); + record.setCurrentRoute(request.getCurrentRoute()); + record.setProvider("spring-ai"); + record.setModel("auto"); + + String systemPrompt = buildSystemPrompt(); + String userPrompt = buildUserPrompt(request); + + try { + log.info("📤 发送命令至 AI 模型: {}", command); + ChatResponse chatResponse = chatClient.prompt() + .system(systemPrompt) + .user(userPrompt) + .call().chatResponse(); + + String rawContent = Optional.ofNullable(chatResponse.getResult()) + .map(result -> result.getOutput().getText()) + .orElse(""); + + ParseResult parseResult = parseAiResponse(rawContent); + + record.setProvider(StrUtil.emptyToDefault(parseResult.provider(), "spring-ai")); + record.setModel(StrUtil.emptyToDefault(parseResult.model(), "auto")); + record.setParseSuccess(parseResult.success()); + record.setExplanation(parseResult.explanation()); + record.setFunctionCalls(JSONUtil.toJsonStr(parseResult.functionCalls())); + record.setConfidence(parseResult.confidence() != null ? BigDecimal.valueOf(parseResult.confidence()) : null); + record.setParseErrorMessage(parseResult.success() ? null : StrUtil.emptyToDefault(parseResult.error(), "解析失败")); + record.setParseTime(System.currentTimeMillis() - startTime); + + recordService.save(record); + + AiParseResponseDTO response = AiParseResponseDTO.builder() + .parseLogId(record.getId()) + .success(parseResult.success()) + .functionCalls(parseResult.functionCalls()) + .explanation(parseResult.explanation()) + .confidence(parseResult.confidence()) + .error(parseResult.error()) + .rawResponse(rawContent) + .build(); + + if (!parseResult.success()) { + log.warn("❗️ AI 未能解析命令: {}", parseResult.error()); + } else { + log.info("✅ 解析成功,审计记录ID: {}", record.getId()); + } + + return response; + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + record.setParseSuccess(false); + record.setFunctionCalls(JSONUtil.toJsonStr(Collections.emptyList())); + record.setParseErrorMessage(e.getMessage()); + record.setParseTime(duration); + recordService.save(record); + + log.error("❌ 解析命令失败: {}", e.getMessage(), e); + throw new RuntimeException("解析命令失败: " + e.getMessage(), e); + } + } + + private String buildSystemPrompt() { + return SYSTEM_PROMPT; + } + + private String buildUserPrompt(AiParseRequestDTO request) { + JSONObject payload = JSONUtil.createObj() + .set("command", request.getCommand()) + .set("currentRoute", request.getCurrentRoute()) + .set("currentComponent", request.getCurrentComponent()) + .set("context", Optional.ofNullable(request.getContext()).orElse(Collections.emptyMap())) + .set("availableFunctions", availableFunctions()); + + return StrUtil.format(""" + 请根据以下上下文识别用户意图,并输出符合系统提示要求的 JSON: + {} + """, JSONUtil.toJsonPrettyStr(payload)); + } + + private List> availableFunctions() { + return List.of( + Map.of( + "name", "updateUserNickname", + "description", "根据用户名更新用户昵称", + "requiredParameters", List.of("username", "nickname") + ) + ); + } + + private ParseResult parseAiResponse(String rawContent) { + if (StrUtil.isBlank(rawContent)) { + throw new IllegalStateException("AI 返回内容为空"); + } + + try { + JSONObject jsonObject = JSONUtil.parseObj(rawContent); + boolean success = jsonObject.getBool("success", false); + String explanation = jsonObject.getStr("explanation"); + Double confidence = jsonObject.containsKey("confidence") ? jsonObject.getDouble("confidence") : null; + String error = jsonObject.getStr("error"); + String provider = jsonObject.getStr("provider"); + String model = jsonObject.getStr("model"); + + List functionCalls = toFunctionCallList(jsonObject.getJSONArray("functionCalls")); + + return new ParseResult(success, explanation, confidence, error, provider, model, functionCalls); + } catch (Exception ex) { + throw new IllegalStateException("无法解析 AI 响应: " + ex.getMessage(), ex); + } + } + + private List toFunctionCallList(JSONArray array) { + if (array == null || array.isEmpty()) { + return Collections.emptyList(); + } + + List result = new ArrayList<>(); + for (Object element : array) { + JSONObject functionJson = JSONUtil.parseObj(element); + Map arguments = Optional.ofNullable(functionJson.getJSONObject("arguments")) + .map(obj -> obj.toBean(new TypeReference>() { + })) + .orElse(Collections.emptyMap()); + + result.add(AiFunctionCallDTO.builder() + .name(functionJson.getStr("name")) + .description(functionJson.getStr("description")) + .arguments(arguments) + .build()); + } + return result; + } + + private record ParseResult( + boolean success, + String explanation, + Double confidence, + String error, + String provider, + String model, + List functionCalls + ) { + } + + @Override + public Object executeCommand(AiExecuteRequestDTO request, HttpServletRequest httpRequest) throws Exception { + long startTime = System.currentTimeMillis(); + + // 获取用户信息 + Long userId = SecurityUtils.getUserId(); + String username = SecurityUtils.getUsername(); + String ipAddress = JakartaServletUtil.getClientIP(httpRequest); + + AiFunctionCallDTO functionCall = request.getFunctionCall(); + + // 判断是否为危险操作 + boolean isDangerous = isDangerousOperation(functionCall.getName()); + + // 根据解析日志ID获取审计记录,如果不存在则创建新记录 + AiCommandRecord record; + if (StrUtil.isNotBlank(request.getParseLogId())) { + // 更新已存在的审计记录(解析阶段已创建) + record = recordService.getById(request.getParseLogId()); + if (record == null) { + throw new IllegalStateException("未找到对应的解析记录,ID: " + request.getParseLogId()); + } + } else { + // 如果没有解析日志ID,创建新记录(兼容直接执行的情况) + record = new AiCommandRecord(); + record.setUserId(userId); + record.setUsername(username); + record.setOriginalCommand(request.getOriginalCommand()); + record.setIpAddress(ipAddress); + record.setCurrentRoute(request.getCurrentRoute()); + recordService.save(record); + } + + // 更新执行相关字段 + record.setFunctionName(functionCall.getName()); + record.setFunctionArguments(JSONUtil.toJsonStr(functionCall.getArguments())); + record.setIsDangerous(isDangerous); + record.setRequiresConfirmation(request.getConfirmMode() != null && + "manual".equals(request.getConfirmMode())); + record.setUserConfirmed(request.getUserConfirmed()); + record.setIdempotencyKey(request.getIdempotencyKey()); + record.setUserAgent(httpRequest.getHeader("User-Agent")); + record.setExecuteStatus("pending"); + + try { + // 幂等性检查 + if (StrUtil.isNotBlank(request.getIdempotencyKey())) { + AiCommandRecord existing = recordService.getOne( + new LambdaQueryWrapper() + .eq(AiCommandRecord::getIdempotencyKey, request.getIdempotencyKey()) + .ne(AiCommandRecord::getId, record.getId()) // 排除当前记录 + ); + if (existing != null) { + log.warn("⚠️ 检测到重复执行,幂等性令牌: {}", request.getIdempotencyKey()); + throw new IllegalStateException("该操作已执行,请勿重复提交"); } + } - try { - // 获取当前提供商(自动校验配置) - AiProvider provider = providerFactory.getCurrentProvider(); - - log.info("📤 使用 {} 解析命令: {}", provider.getProviderName(), request.getCommand()); + // 🎯 执行具体的函数调用 + Object result = executeFunctionCall(functionCall); - // 构建提示词 - String systemPrompt = buildSystemPrompt(); - String userPrompt = buildUserPrompt(request); + // 更新执行成功 + record.setExecuteStatus("success"); + record.setExecuteResult(JSONUtil.toJsonStr(result)); + record.setExecutionTime(System.currentTimeMillis() - startTime); - // 调用 AI API - String response = provider.call(systemPrompt, userPrompt); + // 更新审计记录 + recordService.updateById(record); - // 解析响应 - return parseAiResponse(response); + log.info("✅ 命令执行成功,审计记录ID: {}", record.getId()); - } catch (IllegalStateException e) { - // 配置错误,抛出让 Controller 处理 - throw e; - } catch (Exception e) { - log.error("解析命令失败", e); - throw new RuntimeException("解析命令失败: " + e.getMessage(), e); - } + return result; + + } catch (Exception e) { + // 更新执行失败 + record.setExecuteStatus("failed"); + record.setExecuteErrorMessage(e.getMessage()); + record.setExecutionTime(System.currentTimeMillis() - startTime); + + // 更新审计记录 + recordService.updateById(record); + + log.error("❌ 命令执行失败,审计记录ID: {}", record.getId(), e); + + // 抛出异常,由 Controller 统一处理 + throw e; + } + } + + /** + * 判断是否为危险操作 + */ + private boolean isDangerousOperation(String functionName) { + String[] dangerousKeywords = {"delete", "remove", "drop", "truncate", "clear"}; + String lowerName = functionName.toLowerCase(); + for (String keyword : dangerousKeywords) { + if (lowerName.contains(keyword)) { + return true; + } + } + return false; + } + + /** + * 执行具体的函数调用 + */ + private Object executeFunctionCall(AiFunctionCallDTO functionCall) { + String functionName = functionCall.getName(); + Map arguments = functionCall.getArguments(); + + log.info("🎯 执行函数: {}, 参数: {}", functionName, arguments); + + // 根据函数名称路由到不同的处理器 + switch (functionName) { + case "updateUserNickname": + return executeUpdateUserNickname(arguments); + default: + throw new UnsupportedOperationException("不支持的函数: " + functionName); + } + } + + /** + * 使用 Tool: 根据用户名更新用户昵称 + */ + private Object executeUpdateUserNickname(Map arguments) { + String username = (String) arguments.get("username"); + String nickname = (String) arguments.get("nickname"); + + log.info("🔧 [Tool] 更新用户昵称: username={}, nickname={}", username, nickname); + String resultMsg = userTools.updateUserNickname(username, nickname); + + boolean success = resultMsg != null && resultMsg.contains("成功"); + if (!success) { + throw new RuntimeException(resultMsg != null ? resultMsg : "更新用户昵称失败"); } - /** - * 执行已解析的命令 - */ - @Override - public AiExecuteResponseDTO executeCommand(AiExecuteRequestDTO request, HttpServletRequest httpRequest) { - // TODO: 实现命令执行逻辑 - throw new UnsupportedOperationException("待实现"); - } - - /** - * 获取命令执行历史 - */ - @Override - public Map getCommandHistory(Integer page, Integer size) { - List allAudits = new ArrayList<>(auditStore.values()); - allAudits.sort(Comparator.comparing(AiCommandAudit::getCreateTime).reversed()); - - int total = allAudits.size(); - int start = (page - 1) * size; - int end = Math.min(start + size, total); - - List pageData = start < total ? allAudits.subList(start, end) : new ArrayList<>(); - - Map result = new HashMap<>(); - result.put("list", pageData); - result.put("total", total); - result.put("page", page); - result.put("size", size); - - return result; - } - - /** - * 获取可用的函数列表 - */ - @Override - public List> getAvailableFunctions() { - List> functions = new ArrayList<>(); - - // 用户管理函数 - functions.add(createFunctionDef( - "deleteUser", - "删除用户", - Map.of("name", "String - 用户姓名", "id", "Long - 用户ID(可选)") - )); - - functions.add(createFunctionDef( - "updateUser", - "更新用户信息", - Map.of("id", "Long - 用户ID", "nickname", "String - 昵称", "status", "Integer - 状态") - )); - - functions.add(createFunctionDef( - "queryUsers", - "查询用户列表", - Map.of("name", "String - 姓名(可选)", "status", "Integer - 状态(可选)") - )); - - // 角色管理函数 - functions.add(createFunctionDef( - "assignRole", - "分配角色给用户", - Map.of("userId", "Long - 用户ID", "roleIds", "List - 角色ID列表") - )); - - return functions; - } - - /** - * 撤销命令执行 - */ - @Override - public void rollbackCommand(String auditId) { - AiCommandAudit audit = auditStore.get(auditId); - if (audit == null) { - throw new RuntimeException("审计记录不存在"); - } - - if (!"success".equals(audit.getExecuteStatus())) { - throw new RuntimeException("只能撤销成功执行的命令"); - } - - // TODO: 实现具体的回滚逻辑 - log.info("撤销命令执行: auditId={}, function={}", auditId, audit.getFunctionName()); - throw new UnsupportedOperationException("回滚功能尚未实现"); - } - - // ==================== 私有方法 ==================== - - /** - * 构建系统提示词(包含可用函数定义) - */ - private String buildSystemPrompt() { - return """ - 你是一个专业的命令解析助手。你的任务是将用户的自然语言命令转换为结构化的函数调用。 - - 可用函数: - 1. queryUsers - 查询用户列表 - 参数:keywords(搜索关键字), status(状态), deptId(部门ID) - - 2. deleteUser - 删除用户 - 参数:userId(用户ID) - - 3. updateUser - 更新用户信息 - 参数:userId(用户ID), nickname(昵称), mobile(手机号) - - 请将命令解析为以下 JSON 格式: - { - "functionCalls": [ - { - "function": "函数名", - "parameters": { "参数名": "参数值" }, - "description": "操作说明" - } - ] - } - """; - } - - /** - * 构建用户提示词 - */ - private String buildUserPrompt(AiCommandRequestDTO request) { - return "请解析以下命令:" + request.getCommand(); - } - - /** - * 解析 AI 响应 - */ - private AiCommandResponseDTO parseAiResponse(String response) { - try { - // 提取 JSON - int jsonStart = response.indexOf("{"); - int jsonEnd = response.lastIndexOf("}") + 1; - - if (jsonStart == -1 || jsonEnd == 0) { - throw new IllegalArgumentException("AI 返回格式错误:未找到 JSON"); - } - - String jsonStr = response.substring(jsonStart, jsonEnd); - JSONObject json = JSONUtil.parseObj(jsonStr); - - // 解析函数调用列表 - List functionCalls = new ArrayList<>(); - JSONArray callsArray = json.getJSONArray("functionCalls"); - - if (callsArray != null) { - for (int i = 0; i < callsArray.size(); i++) { - JSONObject call = callsArray.getJSONObject(i); - functionCalls.add(FunctionCallDTO.builder() - .name(call.getStr("function")) - .arguments(call.getJSONObject("parameters") != null ? - call.getJSONObject("parameters").toBean(Map.class) : new HashMap<>()) - .description(call.getStr("description")) - .build()); - } - } - - return AiCommandResponseDTO.builder() - .success(true) - .functionCalls(functionCalls) - .rawResponse(response) - .build(); - - } catch (Exception e) { - log.error("解析 AI 响应失败", e); - throw new RuntimeException("解析响应失败: " + e.getMessage(), e); - } - } - - /** - * 创建函数定义 - */ - private Map createFunctionDef(String name, String description, Map parameters) { - Map func = new HashMap<>(); - func.put("name", name); - func.put("description", description); - func.put("parameters", parameters); - return func; - } + return Map.of("username", username, "nickname", nickname, "message", resultMsg); + } } + diff --git a/src/main/java/com/youlai/boot/platform/ai/tools/UserTools.java b/src/main/java/com/youlai/boot/platform/ai/tools/UserTools.java new file mode 100644 index 00000000..6cff249f --- /dev/null +++ b/src/main/java/com/youlai/boot/platform/ai/tools/UserTools.java @@ -0,0 +1,49 @@ +package com.youlai.boot.platform.ai.tools; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.youlai.boot.system.model.entity.User; +import com.youlai.boot.system.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; + +/** + * 基于 Spring AI Tool 的用户管理工具 + * + * 提供受控的 CRUD 能力,供 LLM 通过 Tool Calling 调用 + */ +@Component +@RequiredArgsConstructor +public class UserTools { + + private final UserService userService; + + @Tool(description = "根据关键字在用户列表中筛选用户") + public String queryUser( + @ToolParam(description = "搜索关键字,用于在列表中搜索筛选") String keywords + ) { + // 返回搜索关键字,前端会在用户列表页面进行筛选 + return "将在用户列表中搜索:" + keywords; + } + + @Tool(description = "根据用户名更新用户昵称") + public String updateUserNickname( + @ToolParam(description = "用户名") String username, + @ToolParam(description = "新的昵称") String nickname + ) { + + boolean ok = userService.update(new LambdaUpdateWrapper() + .eq(User::getUsername, username) + .set(User::getNickname, nickname) + ); + return ok ? "用户昵称更新成功" : "用户昵称更新失败"; + } +} + + + + + + + diff --git a/src/main/java/com/youlai/boot/system/service/WebSocketService.java b/src/main/java/com/youlai/boot/platform/websocket/service/WebSocketService.java similarity index 94% rename from src/main/java/com/youlai/boot/system/service/WebSocketService.java rename to src/main/java/com/youlai/boot/platform/websocket/service/WebSocketService.java index 487412a8..e268b567 100644 --- a/src/main/java/com/youlai/boot/system/service/WebSocketService.java +++ b/src/main/java/com/youlai/boot/platform/websocket/service/WebSocketService.java @@ -1,4 +1,4 @@ -package com.youlai.boot.system.service; +package com.youlai.boot.platform.websocket.service; /** * WebSocket服务接口 diff --git a/src/main/java/com/youlai/boot/system/service/impl/WebSocketServiceImpl.java b/src/main/java/com/youlai/boot/platform/websocket/service/impl/WebSocketServiceImpl.java similarity index 99% rename from src/main/java/com/youlai/boot/system/service/impl/WebSocketServiceImpl.java rename to src/main/java/com/youlai/boot/platform/websocket/service/impl/WebSocketServiceImpl.java index d2fedfed..982f34ae 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/WebSocketServiceImpl.java +++ b/src/main/java/com/youlai/boot/platform/websocket/service/impl/WebSocketServiceImpl.java @@ -1,9 +1,9 @@ -package com.youlai.boot.system.service.impl; +package com.youlai.boot.platform.websocket.service.impl; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.youlai.boot.system.model.dto.DictEventDTO; -import com.youlai.boot.system.service.WebSocketService; +import com.youlai.boot.platform.websocket.service.WebSocketService; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/youlai/boot/system/controller/DictController.java b/src/main/java/com/youlai/boot/system/controller/DictController.java index 537cd87b..45c64280 100644 --- a/src/main/java/com/youlai/boot/system/controller/DictController.java +++ b/src/main/java/com/youlai/boot/system/controller/DictController.java @@ -16,7 +16,7 @@ import com.youlai.boot.system.model.form.DictForm; import com.youlai.boot.common.annotation.Log; import com.youlai.boot.system.service.DictItemService; import com.youlai.boot.system.service.DictService; -import com.youlai.boot.system.service.WebSocketService; +import com.youlai.boot.platform.websocket.service.WebSocketService; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.Operation; diff --git a/src/main/java/com/youlai/boot/system/service/UserOnlineService.java b/src/main/java/com/youlai/boot/system/service/UserOnlineService.java index 8200a457..c0e2a67f 100644 --- a/src/main/java/com/youlai/boot/system/service/UserOnlineService.java +++ b/src/main/java/com/youlai/boot/system/service/UserOnlineService.java @@ -1,10 +1,7 @@ package com.youlai.boot.system.service; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.youlai.boot.security.model.SysUserDetails; import lombok.Data; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.messaging.simp.SimpMessagingTemplate; @@ -30,13 +27,7 @@ public class UserOnlineService { private final Map onlineUsers = new ConcurrentHashMap<>(); private SimpMessagingTemplate messagingTemplate; - private final ObjectMapper objectMapper; - @Autowired - public UserOnlineService(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; - } - @Autowired(required = false) public void setMessagingTemplate(SimpMessagingTemplate messagingTemplate) { this.messagingTemplate = messagingTemplate; diff --git a/src/main/java/com/youlai/boot/system/service/impl/WebSocketSessionCleanupService.java b/src/main/java/com/youlai/boot/system/service/impl/WebSocketSessionCleanupService.java deleted file mode 100644 index f1f2042b..00000000 --- a/src/main/java/com/youlai/boot/system/service/impl/WebSocketSessionCleanupService.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.youlai.boot.system.service.impl; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; - -/** - * WebSocket 会话清理服务 - * - * 功能: - * - 定时清理僵尸会话 - * - 监控会话状态 - * - 输出统计信息 - * - * @author Ray.Hao - * @since 3.0.0 - */ -@Service -@Slf4j -@RequiredArgsConstructor -@ConditionalOnProperty( - prefix = "websocket.session-cleanup", - name = "enabled", - havingValue = "true", - matchIfMissing = true -) -public class WebSocketSessionCleanupService { - - private final WebSocketServiceImpl webSocketService; - - /** - * 定时输出 WebSocket 会话统计信息 - * - * 每 5 分钟执行一次 - */ - @Scheduled(fixedRate = 300000, initialDelay = 60000) - public void logSessionStatistics() { - try { - int onlineUserCount = webSocketService.getOnlineUserCount(); - int totalSessionCount = webSocketService.getTotalSessionCount(); - - log.info("📊 WebSocket 统计 - 在线用户数: {}, 活跃会话数: {}", - onlineUserCount, totalSessionCount); - - // 详细信息(仅在有用户在线时输出) - if (onlineUserCount > 0) { - var onlineUsers = webSocketService.getOnlineUsers(); - onlineUsers.forEach(user -> { - log.debug(" - 用户[{}]: {} 个会话", user.getUsername(), user.getSessionCount()); - }); - } - } catch (Exception ex) { - log.error("❌ 输出会话统计信息失败", ex); - } - } - - /** - * 健康检查 - * - * 每 30 秒执行一次,用于监控服务状态 - */ - @Scheduled(fixedRate = 30000, initialDelay = 10000) - public void healthCheck() { - try { - int onlineUserCount = webSocketService.getOnlineUserCount(); - int sessionCount = webSocketService.getTotalSessionCount(); - - // 异常检测:如果会话数远大于用户数,可能存在会话泄漏 - if (sessionCount > onlineUserCount * 10 && onlineUserCount > 0) { - log.warn("⚠ 检测到异常:会话数({})远大于用户数({}×10),可能存在会话泄漏", - sessionCount, onlineUserCount); - } - } catch (Exception ex) { - log.error("❌ 健康检查失败", ex); - } - } - - /** - * 手动触发在线用户数广播 - * - * 可用于系统启动后的初始化或手动刷新 - */ - public void triggerOnlineCountBroadcast() { - try { - webSocketService.notifyOnlineUsersChange(); - log.info("✓ 手动触发在线用户数广播成功"); - } catch (Exception ex) { - log.error("❌ 手动触发在线用户数广播失败", ex); - } - } -} - diff --git a/src/main/resources/mapper/ai/AiCommandRecordMapper.xml b/src/main/resources/mapper/ai/AiCommandRecordMapper.xml new file mode 100644 index 00000000..0a065278 --- /dev/null +++ b/src/main/resources/mapper/ai/AiCommandRecordMapper.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +