refactor(ai): 引入 Spring AI 简化 LLM 集成,通过 Function Call 为管理系统提供智能化交互入口。

This commit is contained in:
Ray.Hao
2025-11-14 21:48:38 +08:00
parent 95412501fc
commit 0bcaf93193
36 changed files with 1126 additions and 992 deletions

View File

@@ -20,7 +20,7 @@
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<hutool.version>5.8.34</hutool.version>
<hutool.version>5.8.41</hutool.version>
<mysql-connector-j.version>9.1.0</mysql-connector-j.version>
<druid.version>1.2.24</druid.version>
@@ -267,6 +267,13 @@
<version>${dynamic-datasource.version}</version>
</dependency>-->
<!-- Spring AI OpenAI兼容通义千问、DeepSeek等 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0-M6</version>
</dependency>
</dependencies>
<build>

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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<String, ProviderConfig> 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<String> functionWhitelist;
private java.util.List<String> 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);
}
}

View File

@@ -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 {
/**
* 创建 ChatClientSpring AI 核心客户端)
* <p>
* 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();
}
}

View File

@@ -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<AiCommandResponseDTO> parseCommand(
@RequestBody AiCommandRequestDTO request,
HttpServletRequest httpRequest
) {
log.info("收到AI命令解析请求: {}", request.getCommand());
@Operation(summary = "解析自然语言命令")
@PostMapping("/parse")
public Result<AiParseResponseDTO> 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<AiExecuteResponseDTO> 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<Object> 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<AiCommandRecordVO> getRecordPage(AiCommandPageQuery queryParams) {
IPage<AiCommandRecordVO> 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("撤销成功");
}
}

View File

@@ -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<AiCommandAudit> {
}

View File

@@ -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<AiCommandRecord> {
/**
* 获取 AI 命令记录分页列表
*/
IPage<AiCommandRecordVO> getRecordPage(Page<AiCommandRecordVO> page, AiCommandPageQuery queryParams);
}

View File

@@ -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;
}

View File

@@ -43,9 +43,9 @@ public class AiExecuteResponseDTO {
private String error;
/**
* 审计ID用于追踪
* 记录ID用于追踪
*/
private String auditId;
private Long recordId;
/**
* 需要用户确认

View File

@@ -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<String, Object> arguments;
}

View File

@@ -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<String, Object> context;
}

View File

@@ -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<FunctionCallDTO> functionCalls;
private List<AiFunctionCallDTO> functionCalls;
/**
* AI 的理解和说明
@@ -49,5 +54,3 @@ public class AiCommandResponseDTO {
private String rawResponse;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<String> createTime;
@Schema(description = "函数名称")
private String functionName;
}

View File

@@ -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<String> createTime;
}

View File

@@ -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;
}

View File

@@ -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());
}
}

View File

@@ -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();
}

View File

@@ -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<String, AiProvider> 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;
}
}

View File

@@ -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";
}
}

View File

@@ -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";
}
}

View File

@@ -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() : "阿里通义千问";
}
}

View File

@@ -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<AiCommandRecord> {
/**
* 获取命令记录分页列表
*
* @param queryParams 查询参数
* @return 命令记录分页列表
*/
IPage<AiCommandRecordVO> getRecordPage(AiCommandPageQuery queryParams);
/**
* 撤销命令执行
*
* @param recordId 记录ID
*/
void rollbackCommand(String recordId);
}

View File

@@ -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<String, Object> getCommandHistory(Integer page, Integer size);
/**
* 获取可用的函数列表
*
* @return 函数列表
*/
List<Map<String, Object>> getAvailableFunctions();
/**
* 撤销命令执行
*
* @param auditId 审计ID
*/
void rollbackCommand(String auditId);
/**
* 执行已解析的命令
*
* @param request 执行请求
* @param httpRequest HTTP 请求
* @return 执行结果数据(成功时返回)
* @throws Exception 执行失败时抛出异常
*/
Object executeCommand(AiExecuteRequestDTO request, HttpServletRequest httpRequest) throws Exception;
}

View File

@@ -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<AiCommandRecordMapper, AiCommandRecord>
implements AiCommandRecordService {
@Override
public IPage<AiCommandRecordVO> getRecordPage(AiCommandPageQuery queryParams) {
Page<AiCommandRecordVO> 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("回滚功能尚未实现");
}
}

View File

@@ -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<String, AiCommandAudit> 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<Map<String, Object>> 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<AiFunctionCallDTO> 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<AiFunctionCallDTO> toFunctionCallList(JSONArray array) {
if (array == null || array.isEmpty()) {
return Collections.emptyList();
}
List<AiFunctionCallDTO> result = new ArrayList<>();
for (Object element : array) {
JSONObject functionJson = JSONUtil.parseObj(element);
Map<String, Object> arguments = Optional.ofNullable(functionJson.getJSONObject("arguments"))
.map(obj -> obj.toBean(new TypeReference<Map<String, Object>>() {
}))
.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<AiFunctionCallDTO> 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<AiCommandRecord>()
.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<String, Object> arguments = functionCall.getArguments();
log.info("🎯 执行函数: {}, 参数: {}", functionName, arguments);
// 根据函数名称路由到不同的处理器
switch (functionName) {
case "updateUserNickname":
return executeUpdateUserNickname(arguments);
default:
throw new UnsupportedOperationException("不支持的函数: " + functionName);
}
}
/**
* 使用 Tool: 根据用户名更新用户昵称
*/
private Object executeUpdateUserNickname(Map<String, Object> 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<String, Object> getCommandHistory(Integer page, Integer size) {
List<AiCommandAudit> 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<AiCommandAudit> pageData = start < total ? allAudits.subList(start, end) : new ArrayList<>();
Map<String, Object> result = new HashMap<>();
result.put("list", pageData);
result.put("total", total);
result.put("page", page);
result.put("size", size);
return result;
}
/**
* 获取可用的函数列表
*/
@Override
public List<Map<String, Object>> getAvailableFunctions() {
List<Map<String, Object>> 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<Long> - 角色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<FunctionCallDTO> 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<String, Object> createFunctionDef(String name, String description, Map<String, String> parameters) {
Map<String, Object> 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);
}
}

View File

@@ -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<User>()
.eq(User::getUsername, username)
.set(User::getNickname, nickname)
);
return ok ? "用户昵称更新成功" : "用户昵称更新失败";
}
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.system.service;
package com.youlai.boot.platform.websocket.service;
/**
* WebSocket服务接口

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<String, UserOnlineInfo> 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;

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.youlai.boot.platform.ai.mapper.AiCommandRecordMapper">
<resultMap id="AiCommandRecordVOResult" type="com.youlai.boot.platform.ai.model.vo.AiCommandRecordVO">
<id property="id" column="id"/>
<result property="userId" column="user_id"/>
<result property="username" column="username"/>
<result property="originalCommand" column="original_command"/>
<result property="provider" column="provider"/>
<result property="model" column="model"/>
<result property="parseSuccess" column="parse_success"/>
<result property="functionCalls" column="function_calls"/>
<result property="explanation" column="explanation"/>
<result property="confidence" column="confidence"/>
<result property="parseErrorMessage" column="parse_error_message"/>
<result property="inputTokens" column="input_tokens"/>
<result property="outputTokens" column="output_tokens"/>
<result property="totalTokens" column="total_tokens"/>
<result property="parseTime" column="parse_time"/>
<result property="functionName" column="function_name"/>
<result property="functionArguments" column="function_arguments"/>
<result property="executeStatus" column="execute_status"/>
<result property="executeResult" column="execute_result"/>
<result property="executeErrorMessage" column="execute_error_message"/>
<result property="affectedRows" column="affected_rows"/>
<result property="isDangerous" column="is_dangerous"/>
<result property="requiresConfirmation" column="requires_confirmation"/>
<result property="userConfirmed" column="user_confirmed"/>
<result property="executionTime" column="execution_time"/>
<result property="ipAddress" column="ip_address"/>
<result property="userAgent" column="user_agent"/>
<result property="currentRoute" column="current_route"/>
<result property="createTime" column="create_time"/>
<result property="updateTime" column="update_time"/>
<result property="remark" column="remark"/>
</resultMap>
<select id="getRecordPage" resultMap="AiCommandRecordVOResult">
SELECT
acr.id,
acr.user_id,
acr.username,
acr.original_command,
acr.provider,
acr.model,
acr.parse_success,
acr.function_calls,
acr.explanation,
acr.confidence,
acr.parse_error_message,
acr.input_tokens,
acr.output_tokens,
acr.total_tokens,
acr.parse_time,
acr.function_name,
acr.function_arguments,
acr.execute_status,
acr.execute_result,
acr.execute_error_message,
acr.affected_rows,
acr.is_dangerous,
acr.requires_confirmation,
acr.user_confirmed,
acr.execution_time,
acr.ip_address,
acr.user_agent,
acr.current_route,
acr.create_time,
acr.update_time,
acr.remark
FROM ai_command_record acr
<where>
<if test="queryParams.keywords != null and queryParams.keywords != ''">
(
acr.original_command LIKE CONCAT('%', #{queryParams.keywords}, '%')
OR acr.function_name LIKE CONCAT('%', #{queryParams.keywords}, '%')
OR acr.username LIKE CONCAT('%', #{queryParams.keywords}, '%')
)
</if>
<if test="queryParams.executeStatus != null and queryParams.executeStatus != ''">
AND acr.execute_status = #{queryParams.executeStatus}
</if>
<if test="queryParams.userId != null">
AND acr.user_id = #{queryParams.userId}
</if>
<if test="queryParams.isDangerous != null">
AND acr.is_dangerous = #{queryParams.isDangerous}
</if>
<if test="queryParams.functionName != null and queryParams.functionName != ''">
AND acr.function_name = #{queryParams.functionName}
</if>
<if test="
queryParams.createTime != null
and queryParams.createTime.size() == 2
and queryParams.createTime[0] != null
and queryParams.createTime[0] != ''
and queryParams.createTime[1] != null
and queryParams.createTime[1] != ''
">
AND acr.create_time BETWEEN #{queryParams.createTime[0]} AND #{queryParams.createTime[1]}
</if>
</where>
ORDER BY acr.create_time DESC
</select>
</mapper>