9 天以前 007e470ab70d5d4fa503db8b9fc296f531941c5a
feat(ai): 添加审批待办助手功能

- 集成 langchain4j 配置,支持多种 AI 模型包括 DashScope、OpenAI 和 Ollama
- 配置 MongoDB 存储聊天记忆,替换原有的 Redis 配置
- 添加审批待办助手的 Prompt 提示词文件
- 创建 ApproveTodoAgent 接口和相关配置类
- 实现 ApproveTodoIntentExecutor 意图执行器,支持审批相关操作识别
- 开发 ApproveTodoTools 工具类,提供审批待办的增删改查和统计功能
- 实现完整的审批流程管理,包括审核、驳回、修改、删除等操作
- 添加审批数据统计分析功能,支持状态分布和趋势图表
- 集成 ECharts 图表配置,便于前端展示统计结果
已添加10个文件
已修改9个文件
1599 ■■■■ 文件已修改
doc/20260428_create_table_ai_chat_session.sql 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pom.xml 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/assistant/ApproveTodoIntentExecutor.java 85 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/assistant/FileAnalyzeAgent.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/context/AiSessionUserContext.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/controller/XiaozhiController.java 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/dto/AiChatMessageDto.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/dto/AiChatSessionDto.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/mapper/AiChatSessionMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/mongodbBean/ChatMessages.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/pojo/AiChatSession.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/service/AiChatSessionService.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/service/AiFileTextExtractor.java 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/service/impl/AiChatSessionServiceImpl.java 191 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/store/MongoChatMemoryStore.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/tools/ApproveTodoTools.java 794 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/common/config/IgnoreTableConfig.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/framework/security/filter/JwtAuthenticationTokenFilter.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application-dev-pro.yml 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260428_create_table_ai_chat_session.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS `ai_chat_session` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `memory_id` varchar(64) NOT NULL COMMENT '会话ID',
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `tenant_id` bigint DEFAULT NULL COMMENT '租户ID',
  `title` varchar(128) DEFAULT NULL COMMENT '会话标题',
  `last_message` varchar(512) DEFAULT NULL COMMENT '最后一条消息',
  `message_count` int NOT NULL DEFAULT 0 COMMENT '消息数量',
  `last_chat_time` datetime DEFAULT NULL COMMENT '最后聊天时间',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_ai_chat_session_memory_id` (`memory_id`),
  KEY `idx_ai_chat_session_user_tenant` (`user_id`, `tenant_id`),
  KEY `idx_ai_chat_session_last_chat_time` (`last_chat_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='AI历史会话元数据表';
pom.xml
@@ -157,6 +157,10 @@
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j-open-ai-spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j-reactor</artifactId>
        </dependency>
        <dependency>
            <groupId>dev.langchain4j</groupId>
src/main/java/com/ruoyi/ai/assistant/ApproveTodoIntentExecutor.java
@@ -16,6 +16,8 @@
    private static final Pattern APPROVE_ID_PATTERN = Pattern.compile("\\b[A-Za-z]*\\d{8,}\\b");
    private static final Pattern LIMIT_PATTERN = Pattern.compile("(前|最近)?(\\d{1,2})条");
    private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4}-\\d{2}-\\d{2})");
    private static final Pattern NUMBER_PATTERN = Pattern.compile("(\\d+(?:\\.\\d+)?)");
    private final ApproveTodoTools approveTodoTools;
@@ -23,7 +25,7 @@
        this.approveTodoTools = approveTodoTools;
    }
    public String tryExecute(String message) {
    public String tryExecute(String memoryId, String message) {
        if (!StringUtils.hasText(message)) {
            return null;
        }
@@ -32,46 +34,52 @@
        String approveId = extractApproveId(text);
        if (containsAny(text, "统计", "分析", "图表", "趋势", "占比")) {
            return approveTodoTools.getTodoStats();
            return approveTodoTools.getTodoStats(
                    memoryId,
                    extractStartDate(text),
                    extractEndDate(text),
                    extractTimeRange(text)
            );
        }
        if (containsAny(text, "流转", "进度", "节点", "日志")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.getTodoProgress(approveId)
                    ? approveTodoTools.getTodoProgress(memoryId, approveId)
                    : missingApproveId("todo_progress", "查询审批进度需要提供流程编号。");
        }
        if (containsAny(text, "详情", "明细") && !containsAny(text, "列表")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.getTodoDetail(approveId)
                    ? approveTodoTools.getTodoDetail(memoryId, approveId)
                    : missingApproveId("todo_detail", "查询审批详情需要提供流程编号。");
        }
        if (containsAny(text, "取消审核", "撤销审核", "回退审核")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.cancelReviewTodo(approveId, extractTail(text, "原因"))
                    ? approveTodoTools.cancelReviewTodo(memoryId, approveId, extractTail(text, "原因"))
                    : missingApproveId("cancel_review_action", "取消审核需要提供流程编号。");
        }
        if (containsAny(text, "删除")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.deleteTodo(approveId)
                    ? approveTodoTools.deleteTodo(memoryId, approveId)
                    : missingApproveId("delete_action", "删除审批单需要提供流程编号。");
        }
        if (containsAny(text, "驳回", "拒绝")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.reviewTodo(approveId, "reject", extractTail(text, "原因"))
                    ? approveTodoTools.reviewTodo(memoryId, approveId, "reject", extractTail(text, "原因"))
                    : missingApproveId("review_action", "驳回审批需要提供流程编号。");
        }
        if (containsAny(text, "审核通过", "审批通过", "通过审批", "同意审批", "审批同意")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.reviewTodo(approveId, "approve", extractTail(text, "备注"))
                    ? approveTodoTools.reviewTodo(memoryId, approveId, "approve", extractTail(text, "备注"))
                    : missingApproveId("review_action", "审批通过需要提供流程编号。");
        }
        if (StringUtils.hasText(approveId)
                && containsAny(text, "通过", "同意")
                && !containsAny(text, "未通过", "通过率", "审批通过率", "审核通过率")) {
            return approveTodoTools.reviewTodo(approveId, "approve", extractTail(text, "备注"));
            return approveTodoTools.reviewTodo(memoryId, approveId, "approve", extractTail(text, "备注"));
        }
        if (containsAny(text, "修改")) {
            return StringUtils.hasText(approveId)
                    ? approveTodoTools.updateTodo(
                    memoryId,
                    approveId,
                    extractValue(text, "标题"),
                    extractDateValue(text, "开始日期"),
@@ -84,6 +92,7 @@
        }
        if (containsAny(text, "列表", "待办", "查询审批")) {
            return approveTodoTools.listTodos(
                    memoryId,
                    extractStatus(text),
                    extractApproveType(text),
                    extractKeyword(text),
@@ -165,33 +174,75 @@
                .replace("待办", "")
                .replace("列表", "")
                .replace("前10条", "")
                .replace("前20条", "")
                .replace("最近10条", "")
                .trim();
        return cleaned.length() >= 2 ? cleaned : null;
    }
    private String extractValue(String text, String fieldName) {
        Matcher matcher = Pattern.compile(fieldName + "(改为|修改为|为|是)([^,。,;;\\s]+)").matcher(text);
        Pattern pattern = Pattern.compile(fieldName + "(改为|修改为|是)?[::]?[\\s]*([^,,。;;\\s]+)");
        Matcher matcher = pattern.matcher(text);
        return matcher.find() ? matcher.group(2) : null;
    }
    private String extractDateValue(String text, String fieldName) {
        Matcher matcher = Pattern.compile(fieldName + "(改为|修改为|为|是)(\\d{4}-\\d{2}-\\d{2})").matcher(text);
        return matcher.find() ? matcher.group(2) : null;
        int index = text.indexOf(fieldName);
        if (index < 0) {
            return null;
        }
        Matcher matcher = DATE_PATTERN.matcher(text.substring(index));
        return matcher.find() ? matcher.group(1) : null;
    }
    private String extractStartDate(String text) {
        Matcher matcher = DATE_PATTERN.matcher(text);
        return matcher.find() ? matcher.group(1) : null;
    }
    private String extractEndDate(String text) {
        Matcher matcher = DATE_PATTERN.matcher(text);
        if (!matcher.find()) {
            return null;
        }
        return matcher.find() ? matcher.group(1) : null;
    }
    private String extractTimeRange(String text) {
        if (containsAny(text, "今天", "昨日", "昨天", "本周", "上周", "本月", "上月", "本年", "今年", "去年")) {
            return text;
        }
        if (Pattern.compile("近\\d+(天|周|个月|月|å¹´)").matcher(text).find()) {
            return text;
        }
        if (Pattern.compile("最近\\d+(天|周|个月|月|å¹´)").matcher(text).find()) {
            return text;
        }
        if (text.contains("到") || text.contains("至")) {
            return text;
        }
        return null;
    }
    private Integer extractIntegerValue(String text, String fieldName) {
        Matcher matcher = Pattern.compile(fieldName + "(改为|修改为|为|是)(\\d{1,2})").matcher(text);
        if (!text.contains(fieldName)) {
            return null;
        }
        Matcher matcher = Pattern.compile(fieldName + "(改为|修改为|是)?[::]?[\\s]*(\\d{1,2})").matcher(text);
        return matcher.find() ? Integer.parseInt(matcher.group(2)) : null;
    }
    private BigDecimal extractBigDecimalValue(String text, String fieldName) {
        Matcher matcher = Pattern.compile(fieldName + "(改为|修改为|为|是)(\\d+(\\.\\d+)?)").matcher(text);
        return matcher.find() ? new BigDecimal(matcher.group(2)) : null;
        int index = text.indexOf(fieldName);
        if (index < 0) {
            return null;
        }
        Matcher matcher = NUMBER_PATTERN.matcher(text.substring(index));
        return matcher.find() ? new BigDecimal(matcher.group(1)) : null;
    }
    private String extractTail(String text, String key) {
        Matcher matcher = Pattern.compile(key + "(是|为|:|:)?(.+)").matcher(text);
        Pattern pattern = Pattern.compile(key + "(是|为)?[::]?[\\s]*(.+)");
        Matcher matcher = pattern.matcher(text);
        return matcher.find() ? matcher.group(2).trim() : null;
    }
src/main/java/com/ruoyi/ai/assistant/FileAnalyzeAgent.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package com.ruoyi.ai.assistant;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.spring.AiService;
import reactor.core.publisher.Flux;
import static dev.langchain4j.service.spring.AiServiceWiringMode.EXPLICIT;
@AiService(
        wiringMode = EXPLICIT,
        streamingChatModel = "qwenStreamingChatModel",
        chatMemoryProvider = "chatMemoryProvider"
)
public interface FileAnalyzeAgent {
    @SystemMessage("""
            ä½ æ˜¯ä¼ä¸šæ–‡æ¡£åˆ†æžåŠ©æ‰‹ã€‚
            è¯·ä¸¥æ ¼åŸºäºŽç”¨æˆ·æä¾›çš„æ–‡ä»¶å†…容进行分析,输出要结构化、准确、简洁。
            è‹¥æ–‡ä»¶å†…容不足以支持结论,明确指出“不足信息”并给出需要补充的数据项。
            """)
    Flux<String> chat(@MemoryId String memoryId, @UserMessage String userMessage);
}
src/main/java/com/ruoyi/ai/context/AiSessionUserContext.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package com.ruoyi.ai.context;
import com.ruoyi.framework.security.LoginUser;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class AiSessionUserContext {
    private final Map<String, LoginUser> loginUserByMemoryId = new ConcurrentHashMap<>();
    public void bind(String memoryId, LoginUser loginUser) {
        if (!StringUtils.hasText(memoryId) || loginUser == null) {
            return;
        }
        loginUserByMemoryId.put(memoryId, loginUser);
    }
    public LoginUser get(String memoryId) {
        if (!StringUtils.hasText(memoryId)) {
            return null;
        }
        return loginUserByMemoryId.get(memoryId);
    }
    public void remove(String memoryId) {
        if (!StringUtils.hasText(memoryId)) {
            return;
        }
        loginUserByMemoryId.remove(memoryId);
    }
}
src/main/java/com/ruoyi/ai/controller/XiaozhiController.java
@@ -2,35 +2,161 @@
import com.ruoyi.ai.assistant.ApproveTodoAgent;
import com.ruoyi.ai.assistant.ApproveTodoIntentExecutor;
import com.ruoyi.ai.assistant.FileAnalyzeAgent;
import com.ruoyi.ai.bean.ChatForm;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.ai.service.AiChatSessionService;
import com.ruoyi.ai.service.AiFileTextExtractor;
import com.ruoyi.ai.store.MongoChatMemoryStore;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.framework.web.controller.BaseController;
import com.ruoyi.framework.web.domain.AjaxResult;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.UserMessage;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import reactor.core.publisher.Flux;
import java.io.IOException;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;
@Tag(name = "协同办公助手")
@RestController
@RequestMapping("/xiaozhi")
public class XiaozhiController {
public class XiaozhiController extends BaseController {
    private static final String FILE_ANALYZE_MEMORY_PREFIX = "file-analyze::";
    private final ApproveTodoAgent approveTodoAgent;
    private final ApproveTodoIntentExecutor approveTodoIntentExecutor;
    private final FileAnalyzeAgent fileAnalyzeAgent;
    private final AiSessionUserContext aiSessionUserContext;
    private final MongoChatMemoryStore mongoChatMemoryStore;
    private final AiFileTextExtractor aiFileTextExtractor;
    private final AiChatSessionService aiChatSessionService;
    public XiaozhiController(ApproveTodoAgent approveTodoAgent, ApproveTodoIntentExecutor approveTodoIntentExecutor) {
    public XiaozhiController(ApproveTodoAgent approveTodoAgent,
                             ApproveTodoIntentExecutor approveTodoIntentExecutor,
                             FileAnalyzeAgent fileAnalyzeAgent,
                             AiSessionUserContext aiSessionUserContext,
                             MongoChatMemoryStore mongoChatMemoryStore,
                             AiFileTextExtractor aiFileTextExtractor,
                             AiChatSessionService aiChatSessionService) {
        this.approveTodoAgent = approveTodoAgent;
        this.approveTodoIntentExecutor = approveTodoIntentExecutor;
        this.fileAnalyzeAgent = fileAnalyzeAgent;
        this.aiSessionUserContext = aiSessionUserContext;
        this.mongoChatMemoryStore = mongoChatMemoryStore;
        this.aiFileTextExtractor = aiFileTextExtractor;
        this.aiChatSessionService = aiChatSessionService;
    }
    @Operation(summary = "对话")
    @PostMapping(value = "/chat", produces = "text/stream;charset=utf-8")
    public Flux<String> chat(@RequestBody ChatForm chatForm) {
        String directResponse = approveTodoIntentExecutor.tryExecute(chatForm.getMessage());
        if (directResponse != null) {
        if (!StringUtils.hasText(chatForm.getMemoryId())) {
            return Flux.just("memoryId不能为空");
        }
        if (!StringUtils.hasText(chatForm.getMessage())) {
            return Flux.just("message不能为空");
        }
        LoginUser loginUser = SecurityUtils.getLoginUser();
        String memoryId = chatForm.getMemoryId();
        String userMessage = chatForm.getMessage();
        aiSessionUserContext.bind(memoryId, loginUser);
        aiChatSessionService.touchSession(memoryId, loginUser, userMessage);
        String directResponse = approveTodoIntentExecutor.tryExecute(memoryId, userMessage);
        if (StringUtils.isNotEmpty(directResponse)) {
            mongoChatMemoryStore.appendMessages(
                    memoryId,
                    List.of(UserMessage.from(userMessage), AiMessage.from(directResponse))
            );
            aiChatSessionService.refreshSessionStats(memoryId, loginUser);
            return Flux.just(directResponse);
        }
        return approveTodoAgent.chat(chatForm.getMemoryId(), chatForm.getMessage());
        return approveTodoAgent.chat(memoryId, userMessage)
                .doOnComplete(() -> aiChatSessionService.refreshSessionStats(memoryId, loginUser))
                .doOnError(ex -> aiChatSessionService.refreshSessionStats(memoryId, loginUser));
    }
    @Operation(summary = "上传文件分析")
    @PostMapping(value = "/analyze-file", consumes = "multipart/form-data", produces = "text/stream;charset=utf-8")
    public Flux<String> analyzeFile(@RequestParam("file") MultipartFile file,
                                    @RequestParam(value = "message", required = false) String message,
                                    @RequestParam(value = "memoryId", required = false) String memoryId) {
        String rawMemoryId = StringUtils.hasText(memoryId) ? memoryId : UUID.randomUUID().toString();
        String finalMemoryId = rawMemoryId.startsWith(FILE_ANALYZE_MEMORY_PREFIX)
                ? rawMemoryId
                : FILE_ANALYZE_MEMORY_PREFIX + rawMemoryId;
        LoginUser loginUser = SecurityUtils.getLoginUser();
        aiSessionUserContext.bind(finalMemoryId, loginUser);
        String finalMessage = StringUtils.hasText(message) ? message : "请分析这个文件的核心内容并给出总结";
        String fileText;
        try {
            fileText = aiFileTextExtractor.extractText(file);
        } catch (IllegalArgumentException ex) {
            return Flux.just(ex.getMessage());
        } catch (IOException ex) {
            return Flux.just("文件读取失败");
        }
        if (!StringUtils.hasText(fileText)) {
            return Flux.just("未提取到有效文件内容");
        }
        String limitedContent = fileText.length() > 12000 ? fileText.substring(0, 12000) : fileText;
        String userPrompt = "你将分析用户上传的文件内容。\n"
                + "文件名: " + file.getOriginalFilename() + "\n"
                + "用户问题: " + finalMessage + "\n"
                + "文件内容如下:\n"
                + limitedContent;
        aiChatSessionService.touchSession(finalMemoryId, loginUser, "文件分析: " + finalMessage);
        return Flux.defer(() -> fileAnalyzeAgent.chat(finalMemoryId, userPrompt))
                .onErrorResume(NoSuchElementException.class, ex -> {
                    // DashScope åœ¨åŽ†å²æ¶ˆæ¯å…¼å®¹åœºæ™¯ä¸‹å¯èƒ½æŠ› NoSuchElementException,清空后重试一次
                    mongoChatMemoryStore.deleteMessages(finalMemoryId);
                    return fileAnalyzeAgent.chat(finalMemoryId, userPrompt);
                })
                .doOnComplete(() -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser))
                .doOnError(ex -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser));
    }
    @Operation(summary = "会话列表")
    @GetMapping("/history/sessions")
    public AjaxResult listSessions() {
        return success(aiChatSessionService.listCurrentUserSessions(SecurityUtils.getLoginUser()));
    }
    @Operation(summary = "会话消息")
    @GetMapping("/history/messages/{memoryId}")
    public AjaxResult listMessages(@PathVariable String memoryId) {
        return success(aiChatSessionService.listCurrentUserMessages(memoryId, SecurityUtils.getLoginUser()));
    }
    @Operation(summary = "删除会话")
    @DeleteMapping("/history/{memoryId}")
    public AjaxResult deleteSession(@PathVariable String memoryId) {
        aiSessionUserContext.remove(memoryId);
        return toAjax(aiChatSessionService.deleteCurrentUserSession(memoryId, SecurityUtils.getLoginUser()));
    }
}
src/main/java/com/ruoyi/ai/dto/AiChatMessageDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
package com.ruoyi.ai.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AiChatMessageDto {
    private String role;
    private String content;
}
src/main/java/com/ruoyi/ai/dto/AiChatSessionDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package com.ruoyi.ai.dto;
import lombok.Data;
import java.util.Date;
@Data
public class AiChatSessionDto {
    private String memoryId;
    private String title;
    private String lastMessage;
    private Integer messageCount;
    private Date lastChatTime;
}
src/main/java/com/ruoyi/ai/mapper/AiChatSessionMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
package com.ruoyi.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.ai.pojo.AiChatSession;
public interface AiChatSessionMapper extends BaseMapper<AiChatSession> {
}
src/main/java/com/ruoyi/ai/mongodbBean/ChatMessages.java
@@ -5,23 +5,26 @@
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
/**
 * @author :yys
 * @date : 2025/5/2 19:13
 */
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Document("chat_messages")
public class ChatMessages {
    //唯一标识,映射到 MongoDB æ–‡æ¡£çš„ _id å­—段
    @Id
    private ObjectId id;
    private String messageId;
    @Indexed(unique = true)
    private String memoryId;
    private String content; //存储当前聊天记录列表的json字符串
    private String content;
    private Date createTime;
    private Date updateTime;
}
src/main/java/com/ruoyi/ai/pojo/AiChatSession.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
package com.ruoyi.ai.pojo;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
@Data
@TableName("ai_chat_session")
public class AiChatSession {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String memoryId;
    private Long userId;
    private Long tenantId;
    private String title;
    private String lastMessage;
    private Integer messageCount;
    private Date lastChatTime;
    private Date createTime;
    private Date updateTime;
}
src/main/java/com/ruoyi/ai/service/AiChatSessionService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package com.ruoyi.ai.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.ai.dto.AiChatMessageDto;
import com.ruoyi.ai.dto.AiChatSessionDto;
import com.ruoyi.ai.pojo.AiChatSession;
import com.ruoyi.framework.security.LoginUser;
import java.util.List;
public interface AiChatSessionService extends IService<AiChatSession> {
    void touchSession(String memoryId, LoginUser loginUser, String userMessage);
    void refreshSessionStats(String memoryId, LoginUser loginUser);
    List<AiChatSessionDto> listCurrentUserSessions(LoginUser loginUser);
    List<AiChatMessageDto> listCurrentUserMessages(String memoryId, LoginUser loginUser);
    boolean deleteCurrentUserSession(String memoryId, LoginUser loginUser);
}
src/main/java/com/ruoyi/ai/service/AiFileTextExtractor.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,117 @@
package com.ruoyi.ai.service;
import com.ruoyi.common.utils.StringUtils;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.apache.poi.xwpf.extractor.XWPFWordExtractor;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@Component
public class AiFileTextExtractor {
    private static final long MAX_FILE_SIZE = 10L * 1024 * 1024;
    public String extractText(MultipartFile file) throws IOException {
        if (file == null || file.isEmpty()) {
            throw new IllegalArgumentException("文件不能为空");
        }
        if (file.getSize() > MAX_FILE_SIZE) {
            throw new IllegalArgumentException("文件过大,请控制在10MB以内");
        }
        String filename = file.getOriginalFilename();
        String ext = getExtension(filename);
        byte[] bytes = file.getBytes();
        if (isPlainText(ext)) {
            return decodeText(bytes);
        }
        if ("docx".equals(ext)) {
            return extractDocx(bytes);
        }
        if ("xlsx".equals(ext)) {
            return extractXlsx(bytes);
        }
        if ("xls".equals(ext)) {
            return extractXls(bytes);
        }
        throw new IllegalArgumentException("暂不支持该文件类型: " + ext);
    }
    private String extractDocx(byte[] bytes) throws IOException {
        try (ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes);
             XWPFDocument document = new XWPFDocument(inputStream);
             XWPFWordExtractor extractor = new XWPFWordExtractor(document)) {
            return extractor.getText();
        }
    }
    private String extractXlsx(byte[] bytes) throws IOException {
        try (ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes);
             XSSFWorkbook workbook = new XSSFWorkbook(inputStream)) {
            return extractWorkbook(workbook);
        }
    }
    private String extractXls(byte[] bytes) throws IOException {
        try (ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes);
             HSSFWorkbook workbook = new HSSFWorkbook(inputStream)) {
            return extractWorkbook(workbook);
        }
    }
    private String extractWorkbook(org.apache.poi.ss.usermodel.Workbook workbook) {
        StringBuilder text = new StringBuilder();
        DataFormatter formatter = new DataFormatter();
        for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
            Sheet sheet = workbook.getSheetAt(i);
            text.append("Sheet: ").append(sheet.getSheetName()).append("\n");
            for (Row row : sheet) {
                short lastCellNum = row.getLastCellNum();
                if (lastCellNum <= 0) {
                    text.append("\n");
                    continue;
                }
                for (int c = 0; c < lastCellNum; c++) {
                    String cellText = formatter.formatCellValue(row.getCell(c));
                    text.append(cellText);
                    if (c < lastCellNum - 1) {
                        text.append('\t');
                    }
                }
                text.append('\n');
            }
        }
        return text.toString();
    }
    private String decodeText(byte[] bytes) {
        String utf8 = new String(bytes, StandardCharsets.UTF_8);
        if (utf8.contains("�")) {
            return new String(bytes, java.nio.charset.Charset.forName("GBK"));
        }
        return utf8;
    }
    private String getExtension(String filename) {
        if (!StringUtils.hasText(filename) || !filename.contains(".")) {
            return "";
        }
        return filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
    }
    private boolean isPlainText(String ext) {
        return StringUtils.inStringIgnoreCase(ext,
                "txt", "md", "markdown", "json", "xml", "yaml", "yml", "csv", "log", "properties",
                "java", "js", "ts", "vue", "html", "css", "sql", "py", "go", "sh", "bat");
    }
}
src/main/java/com/ruoyi/ai/service/impl/AiChatSessionServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,191 @@
package com.ruoyi.ai.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.ai.dto.AiChatMessageDto;
import com.ruoyi.ai.dto.AiChatSessionDto;
import com.ruoyi.ai.mapper.AiChatSessionMapper;
import com.ruoyi.ai.pojo.AiChatSession;
import com.ruoyi.ai.service.AiChatSessionService;
import com.ruoyi.ai.store.MongoChatMemoryStore;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.security.LoginUser;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.ToolExecutionResultMessage;
import dev.langchain4j.data.message.UserMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class AiChatSessionServiceImpl extends ServiceImpl<AiChatSessionMapper, AiChatSession> implements AiChatSessionService {
    private static final int TITLE_MAX_LENGTH = 40;
    private static final int LAST_MESSAGE_MAX_LENGTH = 300;
    private final MongoChatMemoryStore mongoChatMemoryStore;
    @Override
    public void touchSession(String memoryId, LoginUser loginUser, String userMessage) {
        if (!StringUtils.hasText(memoryId) || loginUser == null || loginUser.getUserId() == null) {
            return;
        }
        Date now = new Date();
        AiChatSession session = getSession(memoryId, loginUser.getUserId(), loginUser.getTenantId());
        if (session == null) {
            AiChatSession add = new AiChatSession();
            add.setMemoryId(memoryId);
            add.setUserId(loginUser.getUserId());
            add.setTenantId(loginUser.getTenantId());
            add.setTitle(buildTitle(userMessage));
            add.setLastMessage(trimText(userMessage, LAST_MESSAGE_MAX_LENGTH));
            add.setMessageCount(0);
            add.setLastChatTime(now);
            add.setCreateTime(now);
            add.setUpdateTime(now);
            save(add);
            return;
        }
        AiChatSession update = new AiChatSession();
        update.setId(session.getId());
        if (!StringUtils.hasText(session.getTitle())) {
            update.setTitle(buildTitle(userMessage));
        }
        update.setLastMessage(trimText(userMessage, LAST_MESSAGE_MAX_LENGTH));
        update.setLastChatTime(now);
        update.setUpdateTime(now);
        updateById(update);
    }
    @Override
    public void refreshSessionStats(String memoryId, LoginUser loginUser) {
        if (!StringUtils.hasText(memoryId) || loginUser == null || loginUser.getUserId() == null) {
            return;
        }
        AiChatSession session = getSession(memoryId, loginUser.getUserId(), loginUser.getTenantId());
        if (session == null) {
            return;
        }
        List<ChatMessage> messages = mongoChatMemoryStore.getMessages(memoryId);
        AiChatSession update = new AiChatSession();
        update.setId(session.getId());
        update.setMessageCount(messages.size());
        update.setLastMessage(trimText(lastMessageText(messages), LAST_MESSAGE_MAX_LENGTH));
        update.setLastChatTime(new Date());
        update.setUpdateTime(new Date());
        updateById(update);
    }
    @Override
    public List<AiChatSessionDto> listCurrentUserSessions(LoginUser loginUser) {
        if (loginUser == null || loginUser.getUserId() == null) {
            return new LinkedList<>();
        }
        LambdaQueryWrapper<AiChatSession> queryWrapper = new LambdaQueryWrapper<AiChatSession>()
                .eq(AiChatSession::getUserId, loginUser.getUserId())
                .orderByDesc(AiChatSession::getLastChatTime);
        applyTenantCondition(queryWrapper, loginUser.getTenantId());
        return list(queryWrapper).stream().map(item -> {
            AiChatSessionDto dto = new AiChatSessionDto();
            dto.setMemoryId(item.getMemoryId());
            dto.setTitle(item.getTitle());
            dto.setLastMessage(item.getLastMessage());
            dto.setMessageCount(item.getMessageCount());
            dto.setLastChatTime(item.getLastChatTime());
            return dto;
        }).collect(Collectors.toList());
    }
    @Override
    public List<AiChatMessageDto> listCurrentUserMessages(String memoryId, LoginUser loginUser) {
        if (!StringUtils.hasText(memoryId) || loginUser == null || loginUser.getUserId() == null) {
            return new LinkedList<>();
        }
        AiChatSession session = getSession(memoryId, loginUser.getUserId(), loginUser.getTenantId());
        if (session == null) {
            return new LinkedList<>();
        }
        List<ChatMessage> messages = mongoChatMemoryStore.getMessages(memoryId);
        return messages.stream().map(this::convertMessage).collect(Collectors.toList());
    }
    @Override
    public boolean deleteCurrentUserSession(String memoryId, LoginUser loginUser) {
        if (!StringUtils.hasText(memoryId) || loginUser == null || loginUser.getUserId() == null) {
            return false;
        }
        LambdaQueryWrapper<AiChatSession> queryWrapper = new LambdaQueryWrapper<AiChatSession>()
                .eq(AiChatSession::getMemoryId, memoryId)
                .eq(AiChatSession::getUserId, loginUser.getUserId());
        applyTenantCondition(queryWrapper, loginUser.getTenantId());
        boolean removed = remove(queryWrapper);
        mongoChatMemoryStore.deleteMessages(memoryId);
        return removed;
    }
    private AiChatSession getSession(String memoryId, Long userId, Long tenantId) {
        LambdaQueryWrapper<AiChatSession> queryWrapper = new LambdaQueryWrapper<AiChatSession>()
                .eq(AiChatSession::getMemoryId, memoryId)
                .eq(AiChatSession::getUserId, userId);
        applyTenantCondition(queryWrapper, tenantId);
        return getOne(queryWrapper, false);
    }
    private void applyTenantCondition(LambdaQueryWrapper<AiChatSession> queryWrapper, Long tenantId) {
        if (tenantId == null) {
            queryWrapper.isNull(AiChatSession::getTenantId);
            return;
        }
        queryWrapper.eq(AiChatSession::getTenantId, tenantId);
    }
    private String buildTitle(String userMessage) {
        if (!StringUtils.hasText(userMessage)) {
            return "新会话";
        }
        return trimText(userMessage, TITLE_MAX_LENGTH);
    }
    private String trimText(String text, int maxLength) {
        if (!StringUtils.hasText(text)) {
            return "";
        }
        String source = text.trim();
        if (source.length() <= maxLength) {
            return source;
        }
        return source.substring(0, maxLength) + "...";
    }
    private String lastMessageText(List<ChatMessage> messages) {
        if (messages == null || messages.isEmpty()) {
            return "";
        }
        ChatMessage lastMessage = messages.get(messages.size() - 1);
        return convertMessage(lastMessage).getContent();
    }
    private AiChatMessageDto convertMessage(ChatMessage message) {
        if (message instanceof UserMessage userMessage) {
            return new AiChatMessageDto("user", userMessage.singleText());
        }
        if (message instanceof AiMessage aiMessage) {
            return new AiChatMessageDto("assistant", aiMessage.text());
        }
        if (message instanceof SystemMessage systemMessage) {
            return new AiChatMessageDto("system", systemMessage.text());
        }
        if (message instanceof ToolExecutionResultMessage toolExecutionResultMessage) {
            return new AiChatMessageDto("tool", toolExecutionResultMessage.text());
        }
        return new AiChatMessageDto("unknown", String.valueOf(message));
    }
}
src/main/java/com/ruoyi/ai/store/MongoChatMemoryStore.java
@@ -5,47 +5,58 @@
import dev.langchain4j.data.message.ChatMessageDeserializer;
import dev.langchain4j.data.message.ChatMessageSerializer;
import dev.langchain4j.store.memory.chat.ChatMemoryStore;
import org.springframework.beans.factory.annotation.Autowired;
import lombok.RequiredArgsConstructor;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;
/**
 * @author :yys
 * @date : 2025/5/2 19:18
 */
@Component
@RequiredArgsConstructor
public class MongoChatMemoryStore implements ChatMemoryStore {
    @Autowired
    private MongoTemplate mongoTemplate;
    private final MongoTemplate mongoTemplate;
    @Override
    public List<ChatMessage> getMessages(Object memoryId) {
        Criteria criteria = Criteria.where("memoryId").is(memoryId);
        Query query = new Query(criteria);
        Query query = Query.query(Criteria.where("memoryId").is(memoryIdString(memoryId)));
        ChatMessages chatMessages = mongoTemplate.findOne(query, ChatMessages.class);
        if(chatMessages == null) return new LinkedList<>();
        if (chatMessages == null || chatMessages.getContent() == null) {
            return new LinkedList<>();
        }
        return ChatMessageDeserializer.messagesFromJson(chatMessages.getContent());
    }
    @Override
    public void updateMessages(Object memoryId, List<ChatMessage> messages) {
        Criteria criteria = Criteria.where("memoryId").is(memoryId);
        Query query = new Query(criteria);
        String memoryIdValue = memoryIdString(memoryId);
        Query query = Query.query(Criteria.where("memoryId").is(memoryIdValue));
        Update update = new Update();
        update.set("memoryId", memoryIdValue);
        update.set("content", ChatMessageSerializer.messagesToJson(messages));
        //根据query条件能查询出文档,则修改文档;否则新增文档
        update.set("updateTime", new Date());
        update.setOnInsert("createTime", new Date());
        mongoTemplate.upsert(query, update, ChatMessages.class);
    }
    @Override
    public void deleteMessages(Object memoryId) {
        Criteria criteria = Criteria.where("memoryId").is(memoryId);
        Query query = new Query(criteria);
        Query query = Query.query(Criteria.where("memoryId").is(memoryIdString(memoryId)));
        mongoTemplate.remove(query, ChatMessages.class);
    }
    public void appendMessages(Object memoryId, List<ChatMessage> appendList) {
        List<ChatMessage> messages = new LinkedList<>(getMessages(memoryId));
        messages.addAll(appendList);
        updateMessages(memoryId, messages);
    }
    private String memoryIdString(Object memoryId) {
        return memoryId == null ? "" : memoryId.toString();
    }
}
src/main/java/com/ruoyi/ai/tools/ApproveTodoTools.java
@@ -2,25 +2,43 @@
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.approve.mapper.ApproveLogMapper;
import com.ruoyi.approve.mapper.ApproveNodeMapper;
import com.ruoyi.approve.mapper.ApproveProcessMapper;
import com.ruoyi.approve.pojo.ApproveLog;
import com.ruoyi.approve.pojo.ApproveNode;
import com.ruoyi.approve.pojo.ApproveProcess;
import com.ruoyi.approve.service.IApproveNodeService;
import com.ruoyi.approve.service.impl.ApproveProcessServiceImpl;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.security.LoginUser;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.agent.tool.ToolMemoryId;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.math.BigDecimal;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.StringJoiner;
import java.util.regex.Matcher;
import java.util.stream.Collectors;
@Component
@@ -34,93 +52,103 @@
    private final ApproveProcessMapper approveProcessMapper;
    private final ApproveNodeMapper approveNodeMapper;
    private final ApproveLogMapper approveLogMapper;
    private final IApproveNodeService approveNodeService;
    private final ApproveProcessServiceImpl approveProcessService;
    private final AiSessionUserContext aiSessionUserContext;
    public ApproveTodoTools(
            ApproveProcessMapper approveProcessMapper,
            ApproveNodeMapper approveNodeMapper,
            ApproveLogMapper approveLogMapper) {
    public ApproveTodoTools(ApproveProcessMapper approveProcessMapper,
                            ApproveNodeMapper approveNodeMapper,
                            ApproveLogMapper approveLogMapper,
                            IApproveNodeService approveNodeService,
                            ApproveProcessServiceImpl approveProcessService,
                            AiSessionUserContext aiSessionUserContext) {
        this.approveProcessMapper = approveProcessMapper;
        this.approveNodeMapper = approveNodeMapper;
        this.approveLogMapper = approveLogMapper;
        this.approveNodeService = approveNodeService;
        this.approveProcessService = approveProcessService;
        this.aiSessionUserContext = aiSessionUserContext;
    }
    @Tool(name = "查询审批待办列表", value = "查询审批待办,支持按状态、类型、关键字过滤,返回 Markdown è¡¨æ ¼ã€‚")
    public String listTodos(
            @P(value = "审批状态,可选值:all、pending、processing、approved、rejected、resubmitted", required = false) String status,
            @P(value = "审批类型编号,可不传", required = false) Integer approveType,
            @P(value = "关键字,可匹配流程编号、标题、申请人、当前审批人", required = false) String keyword,
            @P(value = "返回条数,默认10,最大20", required = false) Integer limit) {
    @Tool(name = "查询审批待办列表", value = "查询当前登录人相关的审批待办,优先返回自己待处理的审批,支持按状态、类型、关键字过滤。")
    public String listTodos(@ToolMemoryId String memoryId,
                            @P(value = "审批状态,可选值:all、pending、processing、approved、rejected、resubmitted", required = false) String status,
                            @P(value = "审批类型编号,可不传", required = false) Integer approveType,
                            @P(value = "关键字,可匹配流程编号、标题、申请人、当前审批人", required = false) String keyword,
                            @P(value = "返回条数,默认10,最大20", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        Long userId = loginUser.getUserId();
        Integer statusCode = parseStatus(status);
        LambdaQueryWrapper<ApproveProcess> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(ApproveProcess::getApproveDelete, 0);
        wrapper.eq(ApproveProcess::getApproveDelete, 0)
                .ne(ApproveProcess::getApproveStatus, 2);
        Integer statusCode = parseStatus(status);
        if (statusCode != null) {
            wrapper.eq(ApproveProcess::getApproveStatus, statusCode);
        }
        if (approveType != null) {
            wrapper.eq(ApproveProcess::getApproveType, approveType);
        }
        if (StringUtils.hasText(keyword)) {
            wrapper.and(w -> w.like(ApproveProcess::getApproveId, keyword)
                    .or().like(ApproveProcess::getApproveReason, keyword)
                    .or().like(ApproveProcess::getApproveUserName, keyword)
                    .or().like(ApproveProcess::getApproveUserCurrentName, keyword));
//        if (StringUtils.hasText(keyword)) {
//            wrapper.and(w -> w.like(ApproveProcess::getApproveId, keyword)
//                    .or().like(ApproveProcess::getApproveReason, keyword)
//                    .or().like(ApproveProcess::getApproveUserName, keyword)
//                    .or().like(ApproveProcess::getApproveUserCurrentName, keyword));
//        }
        if (statusCode != null && (statusCode == 0 || statusCode == 1)) {
            wrapper.eq(ApproveProcess::getApproveUserCurrentId, userId);
        } else {
            wrapper.and(w -> w.eq(ApproveProcess::getApproveUser, userId)
                    .or().eq(ApproveProcess::getApproveUserCurrentId, userId)
                    .or().apply("FIND_IN_SET({0}, approve_user_ids)", userId));
        }
        wrapper.orderByDesc(ApproveProcess::getCreateTime);
        wrapper.last("limit " + normalizeLimit(limit));
        wrapper.orderByDesc(ApproveProcess::getCreateTime)
                .last("limit " + normalizeLimit(limit));
        List<ApproveProcess> processes = approveProcessMapper.selectList(wrapper);
        if (processes == null || processes.isEmpty()) {
            return jsonResponse(
                    true,
                    "todo_list",
                    "未查询到符合条件的审批待办。",
        List<ApproveProcess> processes = defaultList(approveProcessMapper.selectList(wrapper));
        if (processes.isEmpty()) {
            return jsonResponse(true, "todo_list", "未查询到当前用户符合条件的审批待办。",
                    Map.of("count", 0),
                    Map.of(
                            "columns", List.of("approveId", "approveType", "approveUserName", "approveUserCurrentName", "approveReason", "approveStatus", "createTime"),
                            "items", List.of()
                    ),
                    Map.of()
            );
                    Map.of("columns", todoColumns(), "items", List.of()),
                    Map.of());
        }
        List<Map<String, Object>> items = processes.stream().map(process -> {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("approveId", process.getApproveId());
            item.put("approveType", approveTypeName(process.getApproveType()));
            item.put("approveUserName", process.getApproveUserName());
            item.put("approveUserCurrentName", process.getApproveUserCurrentName());
            item.put("approveReason", process.getApproveReason());
            item.put("approveStatus", approveStatusName(process.getApproveStatus()));
            item.put("createTime", formatDateTime(process.getCreateTime()));
            return item;
        }).collect(Collectors.toList());
        List<Map<String, Object>> items = processes.stream()
                .filter(process -> canView(process, userId))
                .sorted(Comparator
                        .comparing((ApproveProcess process) -> !Objects.equals(process.getApproveUserCurrentId(), userId))
                        .thenComparing(ApproveProcess::getCreateTime, Comparator.nullsLast(Comparator.reverseOrder())))
                .map(process -> {
                    Map<String, Object> item = new LinkedHashMap<>();
                    item.put("approveId", process.getApproveId());
                    item.put("approveType", approveTypeName(process.getApproveType()));
                    item.put("approveUserName", safe(process.getApproveUserName()));
                    item.put("approveUserCurrentName", safe(process.getApproveUserCurrentName()));
                    item.put("approveReason", safe(process.getApproveReason()));
                    item.put("approveStatus", approveStatusName(process.getApproveStatus()));
                    item.put("createTime", formatDateTime(process.getCreateTime()));
                    item.put("relation", relationName(process, userId));
                    return item;
                })
                .collect(Collectors.toList());
        return jsonResponse(
                true,
                "todo_list",
                "已返回审批待办列表,可直接渲染表格或卡片。",
        return jsonResponse(true, "todo_list", "已返回当前用户相关审批列表。",
                Map.of(
                        "count", items.size(),
                        "statusFilter", status == null ? "all" : status,
                        "statusFilter", StringUtils.hasText(status) ? status : "all",
                        "approveType", approveType == null ? "" : approveType,
                        "keyword", keyword == null ? "" : keyword
                ),
                Map.of(
                        "columns", List.of("approveId", "approveType", "approveUserName", "approveUserCurrentName", "approveReason", "approveStatus", "createTime"),
                        "items", items
                ),
                Map.of()
        );
                Map.of("columns", todoColumns(), "items", items),
                Map.of());
    }
    @Tool(name = "查询审批待办详情", value = "根据流程编号查询单条审批待办详情,返回结构化文本。")
    public String getTodoDetail(@P("流程编号 approveId") String approveId) {
        ApproveProcess process = getProcessByApproveId(approveId);
    @Tool(name = "查询审批待办详情", value = "根据流程编号查询当前登录人可见的审批详情。")
    public String getTodoDetail(@ToolMemoryId String memoryId,
                                @P("流程编号 approveId") String approveId) {
        ApproveProcess process = getAccessibleProcess(memoryId, approveId);
        if (process == null) {
            return "未找到对应的审批流程,请确认流程编号是否正确。";
            return "未找到对应审批,或当前用户无权查看该流程。";
        }
        StringJoiner detail = new StringJoiner("\n");
@@ -139,255 +167,231 @@
        detail.add("金额: " + (process.getPrice() == null ? "" : process.getPrice().toPlainString()));
        detail.add("备注: " + safe(process.getApproveRemark()));
        detail.add("创建时间: " + formatDateTime(process.getCreateTime()));
        detail.add("与当前用户关系: " + relationName(process, currentUserId(memoryId)));
        return detail.toString();
    }
    @Tool(name = "查询审批流转记录", value = "根据流程编号查询审批节点和审批日志,适合回答进度、卡在哪个节点、谁处理过。")
    public String getTodoProgress(@P("流程编号 approveId") String approveId) {
        ApproveProcess process = getProcessByApproveId(approveId);
    @Tool(name = "查询审批流转记录", value = "根据流程编号查询审批节点和审批日志,用于回答进度、当前卡点和历史处理记录。")
    public String getTodoProgress(@ToolMemoryId String memoryId,
                                  @P("流程编号 approveId") String approveId) {
        ApproveProcess process = getAccessibleProcess(memoryId, approveId);
        if (process == null) {
            return jsonResponse(
                    false,
                    "todo_progress",
                    "未找到对应的审批流程,请确认流程编号是否正确。",
                    Map.of("approveId", approveId == null ? "" : approveId),
            return jsonResponse(false, "todo_progress", "未找到对应审批,或当前用户无权查看该流程。",
                    Map.of("approveId", safe(approveId)),
                    Map.of(),
                    Map.of()
            );
                    Map.of());
        }
        List<ApproveNode> nodes = listNodes(process);
        List<ApproveLog> logs = listLogs(process.getId());
        ApproveNode currentNode = findCurrentNode(nodes);
        List<Map<String, Object>> nodeItems = nodes.stream().map(node -> {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("approveNodeOrder", node.getApproveNodeOrder());
            item.put("approveNodeUser", node.getApproveNodeUser());
            item.put("approveNodeUser", safe(node.getApproveNodeUser()));
            item.put("approveNodeUserId", node.getApproveNodeUserId());
            item.put("approveNodeStatus", approveNodeStatusName(node.getApproveNodeStatus()));
            item.put("approveNodeTime", formatDate(node.getApproveNodeTime()));
            item.put("approveNodeReason", node.getApproveNodeReason());
            item.put("approveNodeRemark", node.getApproveNodeRemark());
            item.put("approveNodeReason", safe(node.getApproveNodeReason()));
            item.put("approveNodeRemark", safe(node.getApproveNodeRemark()));
            item.put("isCurrent", currentNode != null && Objects.equals(currentNode.getId(), node.getId()));
            return item;
        }).collect(Collectors.toList());
        List<Map<String, Object>> logItems = logs.stream().map(log -> {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("approveNodeOrder", log.getApproveNodeOrder());
            item.put("approveUser", log.getApproveUser());
            item.put("approveStatus", approveStatusName(log.getApproveStatus()));
            item.put("approveTime", formatDate(log.getApproveTime()));
            item.put("approveRemark", log.getApproveRemark());
            item.put("approveRemark", safe(log.getApproveRemark()));
            return item;
        }).collect(Collectors.toList());
        return jsonResponse(
                true,
                "todo_progress",
                "已返回审批流转记录,可渲染时间线、步骤条和日志表格。",
        return jsonResponse(true, "todo_progress", "已返回审批流转记录。",
                Map.of(
                        "approveId", process.getApproveId(),
                        "approveId", safe(process.getApproveId()),
                        "currentStatus", approveStatusName(process.getApproveStatus()),
                        "currentApprover", safe(process.getApproveUserCurrentName()),
                        "currentNodeOrder", currentNode == null ? "" : currentNode.getApproveNodeOrder(),
                        "nodeCount", nodeItems.size(),
                        "logCount", logItems.size()
                ),
                Map.of(
                        "nodes", nodeItems,
                        "logs", logItems
                ),
                Map.of()
        );
                Map.of("nodes", nodeItems, "logs", logItems),
                Map.of());
    }
    @Tool(name = "统计审批待办数据", value = "返回专业化统计结果,包含摘要指标和可直接用于 ECharts çš„图表 option。")
    public String getTodoStats() {
        List<ApproveProcess> processes = approveProcessMapper.selectList(new LambdaQueryWrapper<ApproveProcess>()
                .eq(ApproveProcess::getApproveDelete, 0));
        if (processes == null || processes.isEmpty()) {
            return jsonResponse(
                    true,
                    "todo_stats",
                    "当前没有审批待办数据。",
                    Map.of("total", 0),
    @Tool(name = "统计审批待办数据", value = "按用户指定的时间范围统计当前登录人相关审批的状态分布、类型分布和趋势;未指定时默认近7天。")
    public String getTodoStats(@ToolMemoryId String memoryId,
                               @P(value = "开始日期 yyyy-MM-dd,可不传", required = false) String startDate,
                               @P(value = "结束日期 yyyy-MM-dd,可不传", required = false) String endDate,
                               @P(value = "时间范围描述,例如 ä»Šå¤©ã€æœ¬æœˆã€è¿‘30天、2026-04-01到2026-04-27", required = false) String timeRange) {
        Long userId = currentUserId(memoryId);
        List<ApproveProcess> processes = defaultList(approveProcessMapper.selectList(new LambdaQueryWrapper<ApproveProcess>()
                .eq(ApproveProcess::getApproveDelete, 0)
                .and(w -> w.eq(ApproveProcess::getApproveUser, userId)
                        .or().eq(ApproveProcess::getApproveUserCurrentId, userId)
                        .or().apply("FIND_IN_SET({0}, approve_user_ids)", userId))));
        DateRange dateRange = resolveDateRange(startDate, endDate, timeRange);
        List<ApproveProcess> filteredProcesses = processes.stream()
                .filter(process -> withinDateRange(process.getCreateTime(), dateRange))
                .collect(Collectors.toList());
        if (filteredProcesses.isEmpty()) {
            return jsonResponse(true, "todo_stats", "当前用户没有相关审批数据。",
                    Map.of(
                            "total", 0,
                            "startDate", dateRange.start().toString(),
                            "endDate", dateRange.end().toString(),
                            "timeRange", dateRange.label()
                    ),
                    Map.of(
                            "statusDistribution", Map.of(),
                            "typeDistribution", Map.of(),
                            "recent7DayTrend", List.of(),
                            "tips", List.of()
                            "trend", List.of()
                    ),
                    Map.of()
            );
                    Map.of());
        }
        Map<String, Long> statusStats = processes.stream()
        Map<String, Long> statusStats = filteredProcesses.stream()
                .collect(Collectors.groupingBy(p -> approveStatusName(p.getApproveStatus()), LinkedHashMap::new, Collectors.counting()));
        Map<String, Long> typeStats = processes.stream()
        Map<String, Long> typeStats = filteredProcesses.stream()
                .collect(Collectors.groupingBy(p -> approveTypeName(p.getApproveType()), LinkedHashMap::new, Collectors.counting()));
        long pendingCount = countByStatus(processes, 0);
        long processingCount = countByStatus(processes, 1);
        long approvedCount = countByStatus(processes, 2);
        long rejectedCount = countByStatus(processes, 3);
        long resubmittedCount = countByStatus(processes, 4);
        long pendingCount = countByStatus(filteredProcesses, 0);
        long processingCount = countByStatus(filteredProcesses, 1);
        long approvedCount = countByStatus(filteredProcesses, 2);
        long rejectedCount = countByStatus(filteredProcesses, 3);
        long resubmittedCount = countByStatus(filteredProcesses, 4);
        List<String> recentDates = buildRecentDates(7);
        List<Long> trendValues = recentDates.stream()
                .map(date -> processes.stream()
                        .filter(process -> process.getCreateTime() != null)
                        .filter(process -> process.getCreateTime().toLocalDate().equals(LocalDate.parse(date)))
                        .count())
                .collect(Collectors.toList());
        TrendRange trendRange = buildTrendRange(dateRange.start(), dateRange.end(), filteredProcesses);
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("total", processes.size());
        summary.put("total", filteredProcesses.size());
        summary.put("pending", pendingCount);
        summary.put("processing", processingCount);
        summary.put("approved", approvedCount);
        summary.put("rejected", rejectedCount);
        summary.put("resubmitted", resubmittedCount);
        summary.put("approvalCompletionRate", calculateRate(approvedCount, processes.size()));
        summary.put("rejectionRate", calculateRate(rejectedCount, processes.size()));
        summary.put("approvalCompletionRate", calculateRate(approvedCount, filteredProcesses.size()));
        summary.put("rejectionRate", calculateRate(rejectedCount, filteredProcesses.size()));
        summary.put("startDate", dateRange.start().toString());
        summary.put("endDate", dateRange.end().toString());
        summary.put("timeRange", dateRange.label());
        summary.put("trendGranularity", trendRange.granularity());
        Map<String, Object> charts = new LinkedHashMap<>();
        charts.put("statusBarOption", buildStatusBarOption(statusStats));
        charts.put("typePieOption", buildTypePieOption(typeStats));
        charts.put("recentTrendLineOption", buildRecentTrendLineOption(recentDates, trendValues));
        charts.put("trendLineOption", buildTrendLineOption(trendRange.labels(), trendRange.values(), trendRange.label()));
        return jsonResponse(
                true,
                "todo_stats",
                "已返回审批统计概览与图表配置,前端可直接使用 charts ä¸­çš„ ECharts option æ¸²æŸ“。",
        return jsonResponse(true, "todo_stats", "已返回当前用户相关审批统计。",
                summary,
                Map.of(
                        "statusDistribution", statusStats,
                        "typeDistribution", typeStats,
                        "recent7DayTrend", toTrendItems(recentDates, trendValues),
                        "tips", List.of(
                                "statusBarOption é€‚合展示各审批状态数量对比",
                                "typePieOption é€‚合展示各审批类型占比",
                                "recentTrendLineOption é€‚合展示最近7天新增审批趋势"
                        )
                        "trend", toTrendItems(trendRange.labels(), trendRange.values())
                ),
                charts
        );
                charts);
    }
    @Transactional
    @Tool(name = "审批待办", value = "执行审批动作。action åªæ”¯æŒ approve æˆ– reject。approve è¡¨ç¤ºé€šè¿‡ï¼Œreject è¡¨ç¤ºé©³å›žã€‚")
    public String reviewTodo(
            @P("流程编号 approveId") String approveId,
            @P("动作,approve=通过,reject=驳回") String action,
            @P(value = "审批备注,可不传", required = false) String remark) {
        ApproveProcess process = getProcessByApproveId(approveId);
    @Transactional(rollbackFor = Exception.class)
    @Tool(name = "审批待办", value = "执行审批动作,action ä»…支持 approve æˆ– reject,且只能处理当前登录人自己的待审节点。")
    public String reviewTodo(@ToolMemoryId String memoryId,
                             @P("流程编号 approveId") String approveId,
                             @P("动作,approve=通过,reject=驳回") String action,
                             @P(value = "审批备注,可不传", required = false) String remark) {
        ApproveProcess process = getAccessibleProcess(memoryId, approveId);
        if (process == null) {
            return actionResult(false, "review_action", "未找到对应审批流程。", approveId, null);
            return actionResult(false, "review_action", "未找到对应审批,或当前用户无权访问该流程。", approveId, null);
        }
        if (process.getApproveDelete() != null && process.getApproveDelete() == 1) {
            return actionResult(false, "review_action", "该审批流程已删除,不能再审核。", approveId, null);
        if (!canOperate(process, currentUserId(memoryId))) {
            return actionResult(false, "review_action", "当前登录人不是该审批的当前处理人。", approveId, null);
        }
        if (process.getApproveStatus() != null && (process.getApproveStatus() == 2 || process.getApproveStatus() == 3)) {
            return actionResult(false, "review_action", "该审批流程已结束,不能重复审核。", approveId, null);
            return actionResult(false, "review_action", "该审批已结束,不能重复处理。", approveId, null);
        }
        List<ApproveNode> nodes = listNodes(process);
        ApproveNode currentNode = findCurrentNode(nodes);
        if (currentNode == null) {
            return actionResult(false, "review_action", "未找到可审核的当前节点。", approveId, null);
        if (currentNode == null || !Objects.equals(currentNode.getApproveNodeUserId(), currentUserId(memoryId))) {
            return actionResult(false, "review_action", "未找到当前用户可处理的审批节点。", approveId, null);
        }
        String normalizedAction = action == null ? "" : action.trim().toLowerCase();
        Date now = new Date();
        currentNode.setApproveNodeTime(now);
        currentNode.setApproveNodeRemark(remark);
        currentNode.setApproveNodeReason("reject".equals(normalizedAction) ? remark : null);
        currentNode.setUpdateUser(currentUserId(memoryId));
        currentNode.setUpdateTime(LocalDateTime.now());
        currentNode.setIsLast(isLastNode(nodes, currentNode));
        ApproveLog log = new ApproveLog();
        log.setApproveId(process.getId());
        log.setApproveNodeOrder(currentNode.getApproveNodeOrder());
        log.setApproveUser(currentNode.getApproveNodeUserId());
        log.setApproveTime(now);
        log.setApproveRemark(remark);
        if ("approve".equals(normalizedAction)) {
            currentNode.setApproveNodeStatus(1);
            currentNode.setApproveNodeReason(null);
            approveNodeMapper.updateById(currentNode);
            ApproveNode nextNode = findNextNode(nodes, currentNode.getApproveNodeOrder());
            if (nextNode == null) {
                process.setApproveStatus(2);
                process.setApproveOverTime(now);
                process.setApproveUserCurrentId(null);
                process.setApproveUserCurrentName(null);
            } else {
                process.setApproveStatus(1);
                process.setApproveUserCurrentId(nextNode.getApproveNodeUserId());
                process.setApproveUserCurrentName(nextNode.getApproveNodeUser());
        try {
            switch (normalizedAction) {
                case "approve" -> currentNode.setApproveNodeStatus(1);
                case "reject" -> currentNode.setApproveNodeStatus(2);
                default -> {
                    return actionResult(false, "review_action", "action åªæ”¯æŒ approve æˆ– reject。", approveId, null);
                }
            }
            process.setApproveRemark(remark);
            approveProcessMapper.updateById(process);
            log.setApproveStatus(nextNode == null ? 2 : 1);
            approveLogMapper.insert(log);
            return actionResult(true, "review_action",
                    nextNode == null ? "审批已通过,且该流程已全部完成。" : "审批已通过,流程已流转到下一审批节点。",
                    approveId,
                    Map.of(
                            "action", "approve",
                            "currentStatus", approveStatusName(process.getApproveStatus()),
                            "nextApprover", nextNode == null ? "" : safe(nextNode.getApproveNodeUser()),
                            "remark", safe(remark)
                    ));
            approveNodeService.updateApproveNode(currentNode);
        } catch (IOException e) {
            throw new RuntimeException("审批处理失败", e);
        }
        if ("reject".equals(normalizedAction)) {
            currentNode.setApproveNodeStatus(2);
            currentNode.setApproveNodeReason(remark);
            currentNode.setApproveNodeRemark(remark);
            approveNodeMapper.updateById(currentNode);
        ApproveProcess refreshed = getProcessByApproveId(approveId);
        writeApproveLog(memoryId, refreshed, currentNode, remark);
        ApproveNode nextNode = refreshed == null ? null : findCurrentNode(listNodes(refreshed));
            process.setApproveStatus(3);
            process.setApproveOverTime(now);
            process.setApproveUserCurrentId(null);
            process.setApproveUserCurrentName(null);
            process.setApproveRemark(remark);
            approveProcessMapper.updateById(process);
            log.setApproveStatus(3);
            approveLogMapper.insert(log);
            return actionResult(true, "review_action", "审批已驳回。", approveId, Map.of(
                    "action", "reject",
                    "currentStatus", approveStatusName(process.getApproveStatus()),
                    "remark", safe(remark)
            ));
        }
        return actionResult(false, "review_action", "action åªæ”¯æŒ approve æˆ– reject。", approveId, null);
        return actionResult(true, "review_action",
                "approve".equals(normalizedAction) ? "审批已通过。" : "审批已驳回。",
                approveId,
                Map.of(
                        "action", normalizedAction,
                        "currentStatus", refreshed == null ? "" : approveStatusName(refreshed.getApproveStatus()),
                        "nextApprover", nextNode == null ? "" : safe(nextNode.getApproveNodeUser()),
                        "remark", safe(remark)
                ));
    }
    @Transactional
    @Tool(name = "取消审批待办审核", value = "撤销最近一次审核结果,将最近审核节点恢复为未审核,并回滚流程状态。")
    public String cancelReviewTodo(
            @P("流程编号 approveId") String approveId,
            @P(value = "取消原因,可不传", required = false) String reason) {
        ApproveProcess process = getProcessByApproveId(approveId);
    @Transactional(rollbackFor = Exception.class)
    @Tool(name = "取消审批待办审核", value = "撤销最近一次审核结果,仅允许最近一次审核人或申请人操作。")
    public String cancelReviewTodo(@ToolMemoryId String memoryId,
                                   @P("流程编号 approveId") String approveId,
                                   @P(value = "取消原因,可不传", required = false) String reason) {
        ApproveProcess process = getAccessibleProcess(memoryId, approveId);
        if (process == null) {
            return actionResult(false, "cancel_review_action", "未找到对应审批流程。", approveId, null);
            return actionResult(false, "cancel_review_action", "未找到对应审批,或当前用户无权访问该流程。", approveId, null);
        }
        List<ApproveNode> nodes = listNodes(process);
        ApproveNode lastReviewedNode = nodes.stream()
                .filter(node -> node.getApproveNodeStatus() != null && node.getApproveNodeStatus() != 0)
                .max(Comparator.comparing(ApproveNode::getApproveNodeOrder))
                .orElse(null);
        if (lastReviewedNode == null) {
            return actionResult(false, "cancel_review_action", "当前流程没有可取消的审核记录。", approveId, null);
            return actionResult(false, "cancel_review_action", "当前流程没有可撤销的审核记录。", approveId, null);
        }
        Long userId = currentUserId(memoryId);
        if (!isAdmin(userId)
                && !Objects.equals(process.getApproveUser(), userId)
                && !Objects.equals(lastReviewedNode.getApproveNodeUserId(), userId)) {
            return actionResult(false, "cancel_review_action", "只有申请人、最近一次审核人或管理员可以撤销。", approveId, null);
        }
        lastReviewedNode.setApproveNodeStatus(0);
        lastReviewedNode.setApproveNodeTime(null);
        lastReviewedNode.setApproveNodeReason(null);
        lastReviewedNode.setApproveNodeRemark(reason);
        lastReviewedNode.setUpdateUser(userId);
        lastReviewedNode.setUpdateTime(LocalDateTime.now());
        approveNodeMapper.updateById(lastReviewedNode);
        List<ApproveLog> logs = listLogs(process.getId());
        ApproveLog latestLog = logs.stream()
        ApproveLog latestLog = listLogs(process.getId()).stream()
                .max(Comparator.comparing(ApproveLog::getApproveNodeOrder)
                        .thenComparing(ApproveLog::getApproveTime, Comparator.nullsLast(Date::compareTo)))
                .orElse(null);
@@ -395,20 +399,14 @@
            approveLogMapper.deleteById(latestLog.getId());
        }
        ApproveNode previousReviewedNode = nodes.stream()
                .filter(node -> !node.getId().equals(lastReviewedNode.getId()))
                .filter(node -> node.getApproveNodeStatus() != null && node.getApproveNodeStatus() == 1)
                .max(Comparator.comparing(ApproveNode::getApproveNodeOrder))
                .orElse(null);
        process.setApproveOverTime(null);
        process.setApproveRemark(reason);
        process.setApproveStatus(previousReviewedNode == null ? 0 : 1);
        process.setApproveStatus(lastReviewedNode.getApproveNodeOrder() == null || lastReviewedNode.getApproveNodeOrder() <= 1 ? 0 : 1);
        process.setApproveUserCurrentId(lastReviewedNode.getApproveNodeUserId());
        process.setApproveUserCurrentName(lastReviewedNode.getApproveNodeUser());
        approveProcessMapper.updateById(process);
        return actionResult(true, "cancel_review_action", "最近一次审核已取消,流程已回退到对应审批节点。", approveId, Map.of(
        return actionResult(true, "cancel_review_action", "最近一次审核已撤销。", approveId, Map.of(
                "rollbackNodeOrder", lastReviewedNode.getApproveNodeOrder(),
                "currentStatus", approveStatusName(process.getApproveStatus()),
                "currentApprover", safe(process.getApproveUserCurrentName()),
@@ -416,27 +414,36 @@
        ));
    }
    @Transactional
    @Tool(name = "修改审批待办", value = "修改审批单基础信息。支持修改标题、开始日期、结束日期、金额、地点、类型和备注。日期格式必须是 yyyy-MM-dd。")
    public String updateTodo(
            @P("流程编号 approveId") String approveId,
            @P(value = "新的标题,可不传", required = false) String approveReason,
            @P(value = "新的开始日期 yyyy-MM-dd,可不传", required = false) String startDate,
            @P(value = "新的结束日期 yyyy-MM-dd,可不传", required = false) String endDate,
            @P(value = "新的金额,可不传", required = false) BigDecimal price,
            @P(value = "新的地点,可不传", required = false) String location,
            @P(value = "新的审批类型,可不传", required = false) Integer approveType,
            @P(value = "新的备注,可不传", required = false) String approveRemark) {
        ApproveProcess process = getProcessByApproveId(approveId);
    @Transactional(rollbackFor = Exception.class)
    @Tool(name = "修改审批待办", value = "修改审批单基础信息,仅允许申请人修改;不支持通过 AI å˜æ›´å®¡æ‰¹ç±»åž‹ã€‚")
    public String updateTodo(@ToolMemoryId String memoryId,
                             @P("流程编号 approveId") String approveId,
                             @P(value = "新的标题,可不传", required = false) String approveReason,
                             @P(value = "新的开始日期 yyyy-MM-dd,可不传", required = false) String startDate,
                             @P(value = "新的结束日期 yyyy-MM-dd,可不传", required = false) String endDate,
                             @P(value = "新的金额,可不传", required = false) BigDecimal price,
                             @P(value = "新的地点,可不传", required = false) String location,
                             @P(value = "新的审批类型,可不传", required = false) Integer approveType,
                             @P(value = "新的备注,可不传", required = false) String approveRemark) {
        ApproveProcess process = getAccessibleProcess(memoryId, approveId);
        if (process == null) {
            return actionResult(false, "update_action", "未找到对应审批流程。", approveId, null);
            return actionResult(false, "update_action", "未找到对应审批,或当前用户无权访问该流程。", approveId, null);
        }
        if (!Objects.equals(process.getApproveUser(), currentUserId(memoryId)) && !isAdmin(currentUserId(memoryId))) {
            return actionResult(false, "update_action", "只有申请人或管理员可以修改审批单。", approveId, null);
        }
        if (process.getApproveStatus() != null && (process.getApproveStatus() == 1 || process.getApproveStatus() == 2)) {
            return actionResult(false, "update_action", "审批处理中或已完成时,不允许通过 AI ä¿®æ”¹ã€‚", approveId, null);
        }
        if (approveType != null && !Objects.equals(approveType, process.getApproveType())) {
            return actionResult(false, "update_action", "AI åŠ©æ‰‹æš‚ä¸æ”¯æŒç›´æŽ¥å˜æ›´å®¡æ‰¹ç±»åž‹ï¼Œé¿å…èŠ‚ç‚¹é…ç½®å¤±çœŸã€‚", approveId, null);
        }
        if (!StringUtils.hasText(approveReason)
                && !StringUtils.hasText(startDate)
                && !StringUtils.hasText(endDate)
                && price == null
                && !StringUtils.hasText(location)
                && approveType == null
                && !StringUtils.hasText(approveRemark)) {
            return actionResult(false, "update_action", "没有检测到可更新的字段。", approveId, null);
        }
@@ -456,9 +463,6 @@
        if (StringUtils.hasText(location)) {
            process.setLocation(location);
        }
        if (approveType != null) {
            process.setApproveType(approveType);
        }
        if (StringUtils.hasText(approveRemark)) {
            process.setApproveRemark(approveRemark);
        }
@@ -475,30 +479,34 @@
        ));
    }
    @Transactional
    @Tool(name = "删除审批待办", value = "删除审批流程。逻辑删除流程记录,并同步逻辑删除审批节点。")
    public String deleteTodo(@P("流程编号 approveId") String approveId) {
        ApproveProcess process = getProcessByApproveId(approveId);
    @Transactional(rollbackFor = Exception.class)
    @Tool(name = "删除审批待办", value = "删除审批流程,仅允许申请人删除未完成的流程。")
    public String deleteTodo(@ToolMemoryId String memoryId,
                             @P("流程编号 approveId") String approveId) {
        ApproveProcess process = getAccessibleProcess(memoryId, approveId);
        if (process == null) {
            return actionResult(false, "delete_action", "未找到对应审批流程。", approveId, null);
            return actionResult(false, "delete_action", "未找到对应审批,或当前用户无权访问该流程。", approveId, null);
        }
        if (process.getApproveDelete() != null && process.getApproveDelete() == 1) {
            return actionResult(false, "delete_action", "该审批流程已经是删除状态。", approveId, null);
        if (!Objects.equals(process.getApproveUser(), currentUserId(memoryId)) && !isAdmin(currentUserId(memoryId))) {
            return actionResult(false, "delete_action", "只有申请人或管理员可以删除审批单。", approveId, null);
        }
        if (process.getApproveStatus() != null && (process.getApproveStatus() == 1 || process.getApproveStatus() == 2)) {
            return actionResult(false, "delete_action", "审批处理中或已完成的流程不允许通过 AI åˆ é™¤ã€‚", approveId, null);
        }
        process.setApproveDelete(1);
        approveProcessMapper.updateById(process);
        List<ApproveNode> nodes = listNodes(process);
        for (ApproveNode node : nodes) {
            node.setDeleteFlag(1);
            node.setUpdateTime(LocalDateTime.now());
            approveNodeMapper.updateById(node);
        }
        approveProcessService.delByIds(Collections.singletonList(process.getId()));
        return actionResult(true, "delete_action", "审批流程已删除。", approveId, Map.of(
                "deletedNodeCount", nodes.size(),
                "deletedProcessId", process.getId(),
                "approveStatus", approveStatusName(process.getApproveStatus())
        ));
    }
    private ApproveProcess getAccessibleProcess(String memoryId, String approveId) {
        ApproveProcess process = getProcessByApproveId(approveId);
        if (process == null) {
            return null;
        }
        return canView(process, currentUserId(memoryId)) ? process : null;
    }
    private ApproveProcess getProcessByApproveId(String approveId) {
@@ -507,29 +515,31 @@
        }
        return approveProcessMapper.selectOne(new LambdaQueryWrapper<ApproveProcess>()
                .eq(ApproveProcess::getApproveId, approveId)
                .eq(ApproveProcess::getApproveDelete, 0)
                .last("limit 1"));
    }
    private List<ApproveNode> listNodes(ApproveProcess process) {
        List<ApproveNode> nodes = approveNodeMapper.selectList(new LambdaQueryWrapper<ApproveNode>()
        if (process == null) {
            return List.of();
        }
        List<ApproveNode> nodes = defaultList(approveNodeMapper.selectList(new LambdaQueryWrapper<ApproveNode>()
                .eq(ApproveNode::getDeleteFlag, 0)
                .eq(ApproveNode::getApproveProcessId, process.getApproveId())
                .orderByAsc(ApproveNode::getApproveNodeOrder));
        if (nodes != null && !nodes.isEmpty()) {
                .orderByAsc(ApproveNode::getApproveNodeOrder)));
        if (!nodes.isEmpty()) {
            return nodes;
        }
        List<ApproveNode> fallbackNodes = approveNodeMapper.selectList(new LambdaQueryWrapper<ApproveNode>()
        return defaultList(approveNodeMapper.selectList(new LambdaQueryWrapper<ApproveNode>()
                .eq(ApproveNode::getDeleteFlag, 0)
                .eq(ApproveNode::getApproveProcessId, String.valueOf(process.getId()))
                .orderByAsc(ApproveNode::getApproveNodeOrder));
        return fallbackNodes == null ? List.of() : fallbackNodes;
                .orderByAsc(ApproveNode::getApproveNodeOrder)));
    }
    private List<ApproveLog> listLogs(Long processId) {
        List<ApproveLog> logs = approveLogMapper.selectList(new LambdaQueryWrapper<ApproveLog>()
        return defaultList(approveLogMapper.selectList(new LambdaQueryWrapper<ApproveLog>()
                .eq(ApproveLog::getApproveId, processId)
                .orderByAsc(ApproveLog::getApproveNodeOrder, ApproveLog::getApproveTime));
        return logs == null ? List.of() : logs;
                .orderByAsc(ApproveLog::getApproveNodeOrder, ApproveLog::getApproveTime)));
    }
    private ApproveNode findCurrentNode(List<ApproveNode> nodes) {
@@ -539,12 +549,72 @@
                .orElse(null);
    }
    private ApproveNode findNextNode(List<ApproveNode> nodes, Integer currentOrder) {
        return nodes.stream()
                .filter(node -> node.getApproveNodeOrder() != null && currentOrder != null)
                .filter(node -> node.getApproveNodeOrder() > currentOrder)
                .min(Comparator.comparing(ApproveNode::getApproveNodeOrder))
    private boolean isLastNode(List<ApproveNode> nodes, ApproveNode currentNode) {
        Integer maxOrder = nodes.stream()
                .map(ApproveNode::getApproveNodeOrder)
                .filter(Objects::nonNull)
                .max(Integer::compareTo)
                .orElse(null);
        return maxOrder != null && Objects.equals(maxOrder, currentNode.getApproveNodeOrder());
    }
    private void writeApproveLog(String memoryId, ApproveProcess process, ApproveNode currentNode, String remark) {
        if (process == null || currentNode == null) {
            return;
        }
        ApproveLog log = new ApproveLog();
        log.setApproveId(process.getId());
        log.setApproveNodeOrder(currentNode.getApproveNodeOrder());
        log.setApproveUser(currentUserId(memoryId));
        log.setApproveTime(new Date());
        log.setApproveStatus(process.getApproveStatus());
        log.setApproveRemark(remark);
        approveLogMapper.insert(log);
    }
    private boolean canView(ApproveProcess process, Long userId) {
        if (process == null || userId == null) {
            return false;
        }
        return isAdmin(userId)
                || Objects.equals(process.getApproveUser(), userId)
                || Objects.equals(process.getApproveUserCurrentId(), userId)
                || containsUserId(process.getApproveUserIds(), userId);
    }
    private boolean canOperate(ApproveProcess process, Long userId) {
        return process != null && userId != null && Objects.equals(process.getApproveUserCurrentId(), userId);
    }
    private boolean containsUserId(String csv, Long userId) {
        if (!StringUtils.hasText(csv) || userId == null) {
            return false;
        }
        String target = String.valueOf(userId);
        for (String item : csv.split(",")) {
            if (target.equals(item.trim())) {
                return true;
            }
        }
        return false;
    }
    private String relationName(ApproveProcess process, Long userId) {
        if (Objects.equals(process.getApproveUserCurrentId(), userId)) {
            return "当前审批人";
        }
        if (Objects.equals(process.getApproveUser(), userId)) {
            return "申请人";
        }
        if (containsUserId(process.getApproveUserIds(), userId)) {
            return "审批链成员";
        }
        return "可见";
    }
    private List<String> todoColumns() {
        return List.of("approveId", "approveType", "approveUserName", "approveUserCurrentName",
                "approveReason", "approveStatus", "createTime", "relation");
    }
    private int normalizeLimit(Integer limit) {
@@ -640,17 +710,7 @@
        if (total <= 0) {
            return "0.00%";
        }
        double rate = part * 100.0 / total;
        return String.format("%.2f%%", rate);
    }
    private List<String> buildRecentDates(int days) {
        List<String> dates = new ArrayList<>();
        LocalDate today = LocalDate.now();
        for (int i = days - 1; i >= 0; i--) {
            dates.add(today.minusDays(i).toString());
        }
        return dates;
        return String.format("%.2f%%", part * 100.0 / total);
    }
    private List<Map<String, Object>> toTrendItems(List<String> dates, List<Long> values) {
@@ -702,9 +762,9 @@
        return option;
    }
    private Map<String, Object> buildRecentTrendLineOption(List<String> dates, List<Long> values) {
    private Map<String, Object> buildTrendLineOption(List<String> dates, List<Long> values, String label) {
        Map<String, Object> option = new LinkedHashMap<>();
        option.put("title", Map.of("text", "最近7天审批新增趋势", "left", "center"));
        option.put("title", Map.of("text", label + "审批新增趋势", "left", "center"));
        option.put("tooltip", Map.of("trigger", "axis"));
        option.put("xAxis", Map.of("type", "category", "data", dates));
        option.put("yAxis", Map.of("type", "value"));
@@ -726,19 +786,147 @@
        }
    }
    private DateRange resolveDateRange(String startDateText, String endDateText, String timeRange) {
        LocalDate today = LocalDate.now();
        LocalDate explicitStart = parseLocalDate(startDateText);
        LocalDate explicitEnd = parseLocalDate(endDateText);
        if (explicitStart != null || explicitEnd != null) {
            LocalDate start = explicitStart != null ? explicitStart : explicitEnd;
            LocalDate end = explicitEnd != null ? explicitEnd : explicitStart;
            if (start.isAfter(end)) {
                LocalDate temp = start;
                start = end;
                end = temp;
            }
            return new DateRange(start, end, start + "至" + end);
        }
        if (!StringUtils.hasText(timeRange)) {
            return new DateRange(today.minusDays(6), today, "近7天");
        }
        String text = timeRange.trim();
        if (text.contains("今天")) {
            return new DateRange(today, today, "今天");
        }
        if (text.contains("昨天") || text.contains("昨日")) {
            LocalDate day = today.minusDays(1);
            return new DateRange(day, day, "昨天");
        }
        if (text.contains("本周")) {
            LocalDate start = today.minusDays(today.getDayOfWeek().getValue() - 1L);
            return new DateRange(start, today, "本周");
        }
        if (text.contains("上周")) {
            LocalDate thisWeekStart = today.minusDays(today.getDayOfWeek().getValue() - 1L);
            LocalDate start = thisWeekStart.minusWeeks(1);
            LocalDate end = thisWeekStart.minusDays(1);
            return new DateRange(start, end, "上周");
        }
        if (text.contains("本月")) {
            LocalDate start = today.withDayOfMonth(1);
            return new DateRange(start, today, "本月");
        }
        if (text.contains("上月")) {
            YearMonth lastMonth = YearMonth.from(today).minusMonths(1);
            return new DateRange(lastMonth.atDay(1), lastMonth.atEndOfMonth(), "上月");
        }
        if (text.contains("本年") || text.contains("今年")) {
            LocalDate start = today.withDayOfYear(1);
            return new DateRange(start, today, "本年");
        }
        if (text.contains("去年")) {
            LocalDate start = today.minusYears(1).withDayOfYear(1);
            LocalDate end = today.minusYears(1).withMonth(12).withDayOfMonth(31);
            return new DateRange(start, end, "去年");
        }
        Matcher relativeMatcher = java.util.regex.Pattern.compile("(近|最近)(\\d+)(天|周|个月|月|å¹´)").matcher(text);
        if (relativeMatcher.find()) {
            int amount = Integer.parseInt(relativeMatcher.group(2));
            String unit = relativeMatcher.group(3);
            LocalDate start = switch (unit) {
                case "天" -> today.minusDays(Math.max(amount - 1L, 0));
                case "周" -> today.minusWeeks(Math.max(amount, 1)).plusDays(1);
                case "个月", "月" -> today.minusMonths(Math.max(amount, 1)).plusDays(1);
                case "å¹´" -> today.minusYears(Math.max(amount, 1)).plusDays(1);
                default -> today.minusDays(6);
            };
            return new DateRange(start, today, "近" + amount + unit);
        }
        Matcher dateMatcher = java.util.regex.Pattern.compile("(\\d{4}-\\d{2}-\\d{2})").matcher(text);
        if (dateMatcher.find()) {
            LocalDate start = LocalDate.parse(dateMatcher.group(1));
            LocalDate end = dateMatcher.find() ? LocalDate.parse(dateMatcher.group(1)) : start;
            if (start.isAfter(end)) {
                LocalDate temp = start;
                start = end;
                end = temp;
            }
            return new DateRange(start, end, start + "至" + end);
        }
        return new DateRange(today.minusDays(6), today, "近7天");
    }
    private boolean withinDateRange(LocalDateTime createTime, DateRange dateRange) {
        if (createTime == null) {
            return false;
        }
        LocalDate date = createTime.toLocalDate();
        return !date.isBefore(dateRange.start()) && !date.isAfter(dateRange.end());
    }
    private TrendRange buildTrendRange(LocalDate start, LocalDate end, List<ApproveProcess> processes) {
        long days = ChronoUnit.DAYS.between(start, end) + 1;
        if (days <= 31) {
            List<String> labels = new ArrayList<>();
            List<Long> values = new ArrayList<>();
            for (LocalDate cursor = start; !cursor.isAfter(end); cursor = cursor.plusDays(1)) {
                LocalDate current = cursor;
                labels.add(current.toString());
                values.add(processes.stream()
                        .filter(process -> process.getCreateTime() != null)
                        .filter(process -> process.getCreateTime().toLocalDate().equals(current))
                        .count());
            }
            return new TrendRange(labels, values, "day", start + "至" + end);
        }
        List<String> labels = new ArrayList<>();
        List<Long> values = new ArrayList<>();
        YearMonth startMonth = YearMonth.from(start);
        YearMonth endMonth = YearMonth.from(end);
        for (YearMonth cursor = startMonth; !cursor.isAfter(endMonth); cursor = cursor.plusMonths(1)) {
            YearMonth current = cursor;
            labels.add(current.toString());
            values.add(processes.stream()
                    .filter(process -> process.getCreateTime() != null)
                    .filter(process -> YearMonth.from(process.getCreateTime()).equals(current))
                    .count());
        }
        return new TrendRange(labels, values, "month", start + "至" + end);
    }
    private LocalDate parseLocalDate(String text) {
        if (!StringUtils.hasText(text)) {
            return null;
        }
        return LocalDate.parse(text.trim());
    }
    private String actionResult(boolean success, String type, String description, String approveId, Map<String, Object> data) {
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("approveId", approveId == null ? "" : approveId);
        summary.put("approveId", safe(approveId));
        return jsonResponse(success, type, description, summary, data == null ? Map.of() : data, Map.of());
    }
    private String jsonResponse(
            boolean success,
            String type,
            String description,
            Map<String, Object> summary,
            Map<String, Object> data,
            Map<String, Object> charts) {
    private String jsonResponse(boolean success,
                                String type,
                                String description,
                                Map<String, Object> summary,
                                Map<String, Object> data,
                                Map<String, Object> charts) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("success", success);
        result.put("type", type);
@@ -748,4 +936,30 @@
        result.put("charts", charts == null ? Map.of() : charts);
        return JSON.toJSONString(result);
    }
    private LoginUser currentLoginUser(String memoryId) {
        LoginUser loginUser = aiSessionUserContext.get(memoryId);
        if (loginUser != null) {
            return loginUser;
        }
        return SecurityUtils.getLoginUser();
    }
    private Long currentUserId(String memoryId) {
        return currentLoginUser(memoryId).getUserId();
    }
    private boolean isAdmin(Long userId) {
        return SecurityUtils.isAdmin(userId);
    }
    private <T> List<T> defaultList(List<T> list) {
        return list == null ? List.of() : list;
    }
    private record DateRange(LocalDate start, LocalDate end, String label) {
    }
    private record TrendRange(List<String> labels, List<Long> values, String granularity, String label) {
    }
}
src/main/java/com/ruoyi/common/config/IgnoreTableConfig.java
@@ -37,6 +37,7 @@
        IGNORE_TABLES.add("sys_notice");
        IGNORE_TABLES.add("sys_user_client");
        IGNORE_TABLES.add("product_model");
        IGNORE_TABLES.add("ai_chat_session");
        IGNORE_TABLES.add("product");
    }
src/main/java/com/ruoyi/framework/security/filter/JwtAuthenticationTokenFilter.java
@@ -22,23 +22,35 @@
 * @author ruoyi
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
    @Autowired
    private TokenService tokenService;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException
    {
        LoginUser loginUser = tokenService.getLoginUser(request);
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException
    {
        LoginUser loginUser = tokenService.getLoginUser(request);
        if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
        {
            tokenService.verifyToken(loginUser);
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        chain.doFilter(request, response);
    }
}
        }
        chain.doFilter(request, response);
    }
    @Override
    protected boolean shouldNotFilterAsyncDispatch()
    {
        return false;
    }
    @Override
    protected boolean shouldNotFilterErrorDispatch()
    {
        return false;
    }
}
src/main/resources/application-dev-pro.yml
@@ -142,14 +142,15 @@
      enabled: false
  # redis é…ç½®
  data:
    mongodb:
      uri: mongodb://114.132.189.42:9028/chat_memory_db
    # redis é…ç½®
    redis:
      # åœ°å€
  #    host: 127.0.0.1
      host: 47.114.74.44
      host: 127.0.0.1
      #    host: 172.17.0.1
      # ç«¯å£ï¼Œé»˜è®¤ä¸º6379
  #    port: 6379
      port: 6399
      port: 6379
      # æ•°æ®åº“索引
      database: 0
      # å¯†ç 