11 小时以前 a0f6e44368f8c73145039a96fcec77cb5c1f23f9
feat(ai): 添加AI会话清理任务并优化财务分析功能

- 新增AiSessionCleanupTask定时任务,每晚2点清理过期AI会话
- 在AiSessionUserContext中实现会话超时管理和自动清理机制
- 将日期处理从SimpleDateFormat迁移到Java 8 Time API提高性能
- 更新财务分析工具中的成本率配置参数
- 增强财务AI控制器,添加业务数据意图识别功能
- 实现财务风险预警阈值配置化管理
- 优化订单利润分析中的负利润和低利润订单筛选逻辑
已添加1个文件
已修改4个文件
154 ■■■■■ 文件已修改
src/main/java/com/ruoyi/ai/context/AiSessionUserContext.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/controller/FinancialAiController.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/schedule/AiSessionCleanupTask.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/tools/ApproveTodoTools.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/tools/FinancialAgentTools.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/context/AiSessionUserContext.java
@@ -4,6 +4,8 @@
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -11,18 +13,26 @@
public class AiSessionUserContext {
    private final Map<String, LoginUser> loginUserByMemoryId = new ConcurrentHashMap<>();
    private final Map<String, Instant> lastAccessTimeByMemoryId = new ConcurrentHashMap<>();
    private static final Duration SESSION_TIMEOUT = Duration.ofHours(24);
    public void bind(String memoryId, LoginUser loginUser) {
        if (!StringUtils.hasText(memoryId) || loginUser == null) {
            return;
        }
        loginUserByMemoryId.put(memoryId, loginUser);
        lastAccessTimeByMemoryId.put(memoryId, Instant.now());
    }
    public LoginUser get(String memoryId) {
        if (!StringUtils.hasText(memoryId)) {
            return null;
        }
        if (isExpired(memoryId)) {
            remove(memoryId);
            return null;
        }
        lastAccessTimeByMemoryId.put(memoryId, Instant.now());
        return loginUserByMemoryId.get(memoryId);
    }
@@ -31,5 +41,25 @@
            return;
        }
        loginUserByMemoryId.remove(memoryId);
        lastAccessTimeByMemoryId.remove(memoryId);
    }
    public void cleanExpiredSessions() {
        Instant now = Instant.now();
        lastAccessTimeByMemoryId.entrySet().removeIf(entry -> {
            boolean expired = Duration.between(entry.getValue(), now).compareTo(SESSION_TIMEOUT) > 0;
            if (expired) {
                loginUserByMemoryId.remove(entry.getKey());
            }
            return expired;
        });
    }
    private boolean isExpired(String memoryId) {
        Instant lastAccess = lastAccessTimeByMemoryId.get(memoryId);
        if (lastAccess == null) {
            return true;
        }
        return Duration.between(lastAccess, Instant.now()).compareTo(SESSION_TIMEOUT) > 0;
    }
}
src/main/java/com/ruoyi/ai/controller/FinancialAiController.java
@@ -82,6 +82,16 @@
            return Flux.just(directResponse);
        }
        if (isBusinessDataIntent(userMessage)) {
            String noGuessResponse = "未识别到可执行的数据查询条件。为保证结果准确,当前不会推测或编造数据,请补充明确时间范围、客户、供应商或单号后再查询。";
            mongoChatMemoryStore.appendMessages(
                    memoryId,
                    List.of(UserMessage.from(userMessage), AiMessage.from(noGuessResponse))
            );
            aiChatSessionService.refreshSessionStats(memoryId, loginUser);
            return Flux.just(noGuessResponse);
        }
        return financialAgent.chat(memoryId, userMessage, currentDateForPrompt())
                .doOnComplete(() -> aiChatSessionService.refreshSessionStats(memoryId, loginUser))
                .doOnError(ex -> aiChatSessionService.refreshSessionStats(memoryId, loginUser));
@@ -109,4 +119,26 @@
    private String currentDateForPrompt() {
        return LocalDate.now(CHINA_ZONE_ID).format(CURRENT_DATE_FMT);
    }
    private boolean isBusinessDataIntent(String message) {
        if (!StringUtils.hasText(message)) {
            return false;
        }
        String text = message.trim();
        return containsAny(text,
                "查询", "查看", "统计", "分析", "建议", "成本核算", "产品成本", "工序成本",
                "订单利润", "亏损订单", "低利润", "库存资金", "库存积压", "呆滞库存",
                "现金流", "回款风险", "付款压力", "资金缺口", "应收", "应付",
                "异常预警", "经营异常", "风险预警", "驾驶舱", "经营看板", "经营总览",
                "日报", "周报", "经营报告", "分析报告", "业财融合", "口径", "指标解释");
    }
    private boolean containsAny(String text, String... keywords) {
        for (String keyword : keywords) {
            if (text.contains(keyword)) {
                return true;
            }
        }
        return false;
    }
}
src/main/java/com/ruoyi/ai/schedule/AiSessionCleanupTask.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package com.ruoyi.ai.schedule;
import com.ruoyi.ai.context.AiSessionUserContext;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class AiSessionCleanupTask {
    private final AiSessionUserContext aiSessionUserContext;
    public AiSessionCleanupTask(AiSessionUserContext aiSessionUserContext) {
        this.aiSessionUserContext = aiSessionUserContext;
    }
    @Scheduled(cron = "0 0 2 * * ?")
    public void cleanupExpiredSessions() {
        try {
            aiSessionUserContext.cleanExpiredSessions();
        } catch (Exception e) {
            System.err.println("清理过期AI会话失败: " + e.getMessage());
        }
    }
}
src/main/java/com/ruoyi/ai/tools/ApproveTodoTools.java
@@ -22,11 +22,10 @@
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.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
@@ -46,7 +45,7 @@
    private static final int DEFAULT_LIMIT = 10;
    private static final int MAX_LIMIT = 20;
    private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");
    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    private final ApproveProcessMapper approveProcessMapper;
@@ -740,7 +739,10 @@
    }
    private String formatDate(Date value) {
        return value == null ? "" : DATE_FORMAT.format(value);
        if (value == null) {
            return "";
        }
        return value.toInstant().atZone(ZoneId.systemDefault()).toLocalDate().format(DATE_FORMATTER);
    }
    private long countByStatus(List<ApproveProcess> processes, int status) {
@@ -824,8 +826,9 @@
    private Date parseDate(String dateText) {
        try {
            return DATE_FORMAT.parse(dateText);
        } catch (ParseException e) {
            LocalDate localDate = LocalDate.parse(dateText, DATE_FORMATTER);
            return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
        } catch (Exception e) {
            throw new IllegalArgumentException("日期格式必须是 yyyy-MM-dd");
        }
    }
src/main/java/com/ruoyi/ai/tools/FinancialAgentTools.java
@@ -88,7 +88,14 @@
    private static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
    private static final int DEFAULT_LIMIT = 10;
    private static final int MAX_LIMIT = 50;
    private static final BigDecimal DEFAULT_FALLBACK_MATERIAL_COST_RATE = new BigDecimal("0.65");
    private static final BigDecimal DEFAULT_FALLBACK_MATERIAL_COST_RATE = new BigDecimal("0.60");
    private static final BigDecimal DEFAULT_LABOR_COST_RATE = new BigDecimal("0.15");
    private static final BigDecimal DEFAULT_OVERHEAD_COST_RATE = new BigDecimal("0.10");
    private static final BigDecimal SME_RECEIVABLE_RISK_THRESHOLD = new BigDecimal("500000");
    private static final BigDecimal SME_INVENTORY_RISK_THRESHOLD = new BigDecimal("1000000");
    private static final BigDecimal SME_PROFIT_WARNING_RATE = new BigDecimal("0.08");
    private final SalesLedgerMapper salesLedgerMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
@@ -194,6 +201,10 @@
                                           @P(value = "关键词,可匹配合同号/客户/项目", required = false) String keyword,
                                           @P(value = "返回条数,默认10,最大50", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        if (loginUser == null) {
            return jsonResponse(false, "financial_cost_accounting", "用户信息获取失败", Map.of(), Map.of(), Map.of());
        }
        DateRange range = resolveDateRange(startDate, endDate, timeRange, "近30天");
        AnalysisBundle bundle = buildOrderProfitBundle(loginUser, range, keyword, limit);
@@ -248,12 +259,16 @@
                                     @P(value = "关键词,可匹配合同号/客户/项目", required = false) String keyword,
                                     @P(value = "返回条数,默认10,最大50", required = false) Integer limit) {
        LoginUser loginUser = currentLoginUser(memoryId);
        if (loginUser == null) {
            return jsonResponse(false, "financial_order_profit_analysis", "用户信息获取失败", Map.of(), Map.of(), Map.of());
        }
        DateRange range = resolveDateRange(startDate, endDate, timeRange, "近30天");
        AnalysisBundle bundle = buildOrderProfitBundle(loginUser, range, keyword, limit);
        List<OrderProfitMetric> metrics = bundle.orderMetrics();
        List<OrderProfitMetric> riskyOrders = metrics.stream()
                .filter(item -> item.profit().compareTo(BigDecimal.ZERO) < 0 || item.profitRate().compareTo(new BigDecimal("0.08")) < 0)
                .filter(item -> item.profit().compareTo(BigDecimal.ZERO) < 0 || item.profitRate().compareTo(SME_PROFIT_WARNING_RATE) < 0)
                .sorted(Comparator.comparing(OrderProfitMetric::profitRate)
                        .thenComparing(OrderProfitMetric::profit))
                .toList();
@@ -594,10 +609,10 @@
        if (lossCount > 0) {
            riskSuggestions.add(riskSuggestion("利润风险", "高", "复核亏损订单BOM和工序工资定额,必要时调整报价与交付节奏。"));
        }
        if (snapshot.receivableTotal().compareTo(new BigDecimal("1000000")) > 0) {
        if (snapshot.receivableTotal().compareTo(SME_RECEIVABLE_RISK_THRESHOLD) > 0) {
            riskSuggestions.add(riskSuggestion("回款风险", "中", "对应收TOP客户建立周度回款计划,并设置预警阈值。"));
        }
        if (inventoryValue.compareTo(new BigDecimal("3000000")) > 0) {
        if (inventoryValue.compareTo(SME_INVENTORY_RISK_THRESHOLD) > 0) {
            riskSuggestions.add(riskSuggestion("库存风险", "中", "对高金额呆滞库存执行降价、替代和生产消耗策略。"));
        }
@@ -1319,7 +1334,35 @@
                return productAmount;
            }
        }
        return revenue.multiply(DEFAULT_FALLBACK_MATERIAL_COST_RATE);
        BigDecimal materialCost = revenue.multiply(DEFAULT_FALLBACK_MATERIAL_COST_RATE);
        BigDecimal laborCost = revenue.multiply(DEFAULT_LABOR_COST_RATE);
        BigDecimal overheadCost = revenue.multiply(DEFAULT_OVERHEAD_COST_RATE);
        return materialCost.add(laborCost).add(overheadCost);
    }
    private BigDecimal estimateTotalCost(BigDecimal revenue, List<SalesLedgerProduct> products) {
        if (revenue == null || revenue.compareTo(BigDecimal.ZERO) <= 0) {
            return BigDecimal.ZERO;
        }
        BigDecimal materialCost = BigDecimal.ZERO;
        if (products != null && !products.isEmpty()) {
            materialCost = products.stream()
                    .map(SalesLedgerProduct::getTaxExclusiveTotalPrice)
                    .filter(Objects::nonNull)
                    .reduce(BigDecimal.ZERO, BigDecimal::add);
        }
        if (materialCost.compareTo(BigDecimal.ZERO) <= 0) {
            materialCost = revenue.multiply(DEFAULT_FALLBACK_MATERIAL_COST_RATE);
        }
        BigDecimal laborCost = revenue.multiply(DEFAULT_LABOR_COST_RATE);
        BigDecimal overheadCost = revenue.multiply(DEFAULT_OVERHEAD_COST_RATE);
        return materialCost.add(laborCost).add(overheadCost);
    }
    private Map<String, String> queryCustomerNameMap(Set<String> idSet) {