AI机器人客服-Dify接入

一.前端代码

1.AIChat/index.vue

html 复制代码
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted } from "vue";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import {
  chatBlocking,
  streamChat,
  getChatHistory,
  getConversations,
  submitFeedback,
  type ChatRequest,
  type ChatMessageVO,
  type ConversationVO,
  type StreamChatCallbacks
} from "@/api/aiChat";
import aiLogo from "@/assets/ai/AIREBOT3.png";
import { useUserStoreHook } from "@/store/modules/user";

// 路由
const route = useRoute();

// 组件状态
const isOpen = ref(false);
const inputMessage = ref("");
const isLoading = ref(false);
const currentConversationId = ref<string>("");
const messages = ref<ChatMessageVO[]>([]);
const conversations = ref<ConversationVO[]>([]);
const showHistory = ref(false);
const messagesContainerRef = ref<HTMLElement>();

// 是否启用流式对话(默认为 true,可通过配置关闭)
const enableStreaming = ref(true);
// 当前流式请求控制器(用于流式对话)
let currentStreamController: { close: () => void } | null = null;

// 当前用户信息
const userInfo = computed(() => {
  try {
    return useUserStoreHook()?.userInfo;
  } catch (e) {
    return null;
  }
});

// 设备信息(从路由参数获取)
const deviceSn = ref(route.query.deviceSn as string || "");
const batteryId = ref(route.query.batteryId as string || "");

// 示例问题
const sampleQuestions = [
  "高温阈值应该设置多少?",
  "如何批量配置设备参数?",
  "DEVICE和MODEL有什么区别?"
];

// 快捷问题
const quickQuestions = [
  "什么设备及覆盖?",
  "如何配置高温阈值?",
  "参数优先级?",
  "什么是参数优先级?"
];

// 打开/关闭聊天窗口
const toggleChat = () => {
  isOpen.value = !isOpen.value;
  if (isOpen.value && messages.value.length === 0) {
    // 首次打开时加载会话列表
    loadConversations();
  }
  nextTick(() => {
    scrollToBottom();
  });
};

// 关闭聊天窗口
const closeChat = () => {
  isOpen.value = false;
};

// 加载会话列表
const loadConversations = async () => {
  try {
    const params: any = { limit: 20 };
    // 如果有设备信息,传递给后端
    if (deviceSn.value) {
      params.deviceSn = deviceSn.value;
    }
    if (batteryId.value) {
      params.batteryId = batteryId.value;
    }

    const res = await getConversations(params);
    if (res.code === 200 && res.data) {
      conversations.value = res.data;
    }
  } catch (error) {
    console.error("加载会话列表失败:", error);
  }
};

// 加载历史消息
const loadHistory = async (conversationId: string) => {
  try {
    const res = await getChatHistory({
      conversationId,
      limit: 50
    });
    if (res.code === 200 && res.data) {
      messages.value = res.data.data || [];
      currentConversationId.value = conversationId;
      showHistory.value = false;
      nextTick(() => {
        scrollToBottom();
      });
    }
  } catch (error) {
    console.error("加载历史记录失败:", error);
    ElMessage.error("加载历史记录失败");
  }
};

// 创建新会话
const createNewChat = () => {
  currentConversationId.value = "";
  messages.value = [];
  showHistory.value = false;
  inputMessage.value = "";
};

// 发送消息(自动选择流式或阻塞式)
const sendMessage = async () => {
  const message = inputMessage.value.trim();
  if (!message || isLoading.value) return;

  // 添加用户消息到列表
  const userMessage: ChatMessageVO = {
    messageId: `temp_${Date.now()}`,
    query: message,
    answer: "",
    totalTokens: 0,
    latencyMs: 0,
    createdAt: new Date().toISOString()
  };
  messages.value.push(userMessage);
  inputMessage.value = "";
  isLoading.value = true;

  nextTick(() => {
    scrollToBottom();
  });

  // 根据配置选择调用方式
  if (enableStreaming.value) {
    await sendStreamMessage(message);
  } else {
    await sendBlockingMessage(message);
  }
};

// 流式发送消息(SSE)
const sendStreamMessage = async (message: string) => {
  const lastMessage = messages.value[messages.value.length - 1];
  let fullAnswer = "";
  let fullThinking = "";
  let inThinking = false;
  let thinkingFinished = false;
  let messageId = "";
  let conversationId = "";

  try {
    console.log("[Chat] 开始流式请求...");
    // 创建流式请求,使用回调函数处理消息
    currentStreamController = await streamChat(
      message,
      currentConversationId.value || undefined,
      deviceSn.value || undefined,
      batteryId.value ? Number(batteryId.value) : undefined,
      {
        // 收到消息片段时调用
        onMessage: (data) => {
          try {
            // console.log("[Chat] === 收到 onMessage 回调 ===");
            // console.log("[Chat] 完整数据:", data);
            //console.log("[Chat] answer 字段:", data.answer);

            // 处理正常消息
            if (data.message_id) {
              messageId = data.message_id;
              //console.log("[Chat] 记录 messageId:", messageId);
            }
            if (data.conversation_id) {
              conversationId = data.conversation_id;
              //console.log("[Chat] 记录 conversationId:", conversationId);
            }
            // 修复:处理 answer 字段(支持累积和增量两种模式)
            if (data.answer !== undefined) {
              const chunk = data.answer;
              //console.log("[Chat] 准备处理 chunk,长度:", chunk.length);

              // 处理 <think> 标签,提取思考过程
              if (chunk.includes("<think>")) {
                inThinking = true;
                thinkingFinished = false;
                const thinkStart = chunk.indexOf("<think>");
                if (thinkStart > 0) {
                  // <think> 之前有内容,属于回答部分
                  fullAnswer += chunk.substring(0, thinkStart);
                }
                const thinkContent = chunk.substring(thinkStart + 7); // 去掉 <think>
                if (thinkContent) {
                  fullThinking += thinkContent;
                }
                console.log("[Chat] 进入思考模式,累积思考:", fullThinking);
              } else if (chunk.includes("</think>")) {
                inThinking = false;
                thinkingFinished = true;
                const thinkEnd = chunk.indexOf("</think>");
                fullThinking += chunk.substring(0, thinkEnd);
                const afterThink = chunk.substring(thinkEnd + 8); // 去掉 </think>
                if (afterThink) {
                  fullAnswer += afterThink;
                }
                console.log("[Chat] 思考结束,累积答案:", fullAnswer);

                // 思考结束后,自动折叠思考过程
                const lastIndex = messages.value.length - 1;
                if (messages.value[lastIndex].thinkingExpanded === undefined) {
                  messages.value[lastIndex].thinkingExpanded = true; // 初始默认展开
                }
                // 延迟一点时间后自动折叠
                setTimeout(() => {
                  messages.value[lastIndex].thinkingExpanded = false;
                }, 500);
              } else if (inThinking) {
                fullThinking += chunk;
                console.log("[Chat] 累积思考内容:", fullThinking);
              } else {
                // 直接累加答案内容
                fullAnswer += chunk;
                console.log("[Chat] 累积答案内容:", fullAnswer);
              }

              //console.log("[Chat] 累加后的 fullAnswer:", fullAnswer);
              //console.log("[Chat] 累加后的 fullThinking:", fullThinking);

              // 使用数组索引更新确保响应式触发
              const lastIndex = messages.value.length - 1;
              messages.value[lastIndex] = {
                ...messages.value[lastIndex],
                answer: fullAnswer,
                thinking: fullThinking || undefined
              };
              // console.log("[Chat] 更新后的消息对象:", messages.value[lastIndex]);
              nextTick(() => scrollToBottom());
            } else {
              console.log("[Chat] answer 为空或 undefined,跳过处理");
            }
          } catch (e) {
            console.error("[Chat] 解析流式消息失败:", e);
          }
        },
        // 收到结束事件时调用
        onEnd: (data) => {
          try {
            console.log("[Chat] 收到结束事件:", data);
            // 流式对话完成(后端 event: end)
            if (conversationId) {
              currentConversationId.value = conversationId;
            }
            lastMessage.messageId = messageId;
            isLoading.value = false;
            currentStreamController = null;
            loadConversations();
          } catch (e) {
            console.error("[Chat] 解析 end 事件失败:", e);
          }
        },
        // 发生错误时调用
        onError: (error) => {
          console.error("流式对话错误:", error);
          currentStreamController = null;

          // 如果流式对话失败且还没有收到任何内容
          // TODO: 联调完成后取消注释
          if (!fullAnswer) {
            console.log("流式对话失败,降级到阻塞式对话");
            sendBlockingMessage(message);
            return;
          }

          // 已经收到了部分内容,标记为完成
          isLoading.value = false;
          if (!lastMessage.answer) {
            lastMessage.answer = "抱歉,连接中断,请稍后重试。";
          }
        }
      }
    );

  } catch (error: any) {
    console.error("流式对话初始化失败:", error);
    currentStreamController = null;
    isLoading.value = false;
    // 流式失败,降级到阻塞式
    // TODO: 联调完成后取消注释
    // await sendBlockingMessage(message);
  }
};

// 阻塞式发送消息(作为降级方案)
const sendBlockingMessage = async (message: string) => {
  const lastMessage = messages.value[messages.value.length - 1];

  try {
    const request: ChatRequest = {
      query: message,
      conversationId: currentConversationId.value || undefined,
      deviceSn: deviceSn.value || undefined,
      batteryId: batteryId.value ? Number(batteryId.value) : undefined,
      responseMode: "blocking"
    };

    const res = await chatBlocking(request);

    if (res.code === 200 && res.data) {
      const response = res.data;
      // 更新会话 ID
      if (response.conversationId) {
        currentConversationId.value = response.conversationId;
      }

      // 更新最后一条消息
      lastMessage.answer = response.answer;
      lastMessage.messageId = response.messageId;
      lastMessage.latencyMs = response.latencyMs;

      // 刷新会话列表
      loadConversations();
    } else {
      throw new Error(res.message || "发送失败");
    }
  } catch (error: any) {
    console.error("阻塞式对话失败:", error);
    ElMessage.error(error.message || "发送失败,请稍后重试");
    lastMessage.answer = "抱歉,服务暂时不可用,请稍后重试。";
  } finally {
    isLoading.value = false;
    nextTick(() => {
      scrollToBottom();
    });
  }
};

// 组件卸载时关闭流式请求
onUnmounted(() => {
  document.removeEventListener("click", handleClickOutside);
  if (currentStreamController) {
    currentStreamController.close();
    currentStreamController = null;
  }
});

// 发送示例问题
const sendSampleQuestion = (question: string) => {
  inputMessage.value = question;
  sendMessage();
};

// 提交反馈
const handleFeedback = async (
  messageId: string,
  feedback: number,
  reason?: string
) => {
  try {
    const res = await submitFeedback({ messageId, feedback, reason });
    if (res.code === 200) {
      ElMessage.success(feedback === 1 ? "感谢您的点赞!" : "感谢您的反馈!");
      // 更新本地消息状态
      const message = messages.value.find(m => m.messageId === messageId);
      if (message) {
        message.feedback = feedback;
      }
    }
  } catch (error) {
    console.error("提交反馈失败:", error);
    ElMessage.error("提交反馈失败");
  }
};

// 切换思考过程的展开/收起状态
const toggleThinking = (msg: ChatMessageVO) => {
  // 如果还没有设置过 expanded 属性,默认为 true(展开),然后切换
  if (msg.thinkingExpanded === undefined) {
    msg.thinkingExpanded = true;
  }
  msg.thinkingExpanded = !msg.thinkingExpanded;
};

// 滚动到底部
const scrollToBottom = () => {
  if (messagesContainerRef.value) {
    messagesContainerRef.value.scrollTop = messagesContainerRef.value.scrollHeight;
  }
};

// 格式化时间(聊天对话底部)
const formatTime = (timeStr: string) => {
  if (!timeStr) return "";
  const date = new Date(timeStr);
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, '0');
  const day = String(date.getDate()).padStart(2, '0');
  const hours = String(date.getHours()).padStart(2, '0');
  const minutes = String(date.getMinutes()).padStart(2, '0');
  const seconds = String(date.getSeconds()).padStart(2, '0');
  return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};

// 格式化日期(用于会话列表)
const formatDate = (timeStr: string) => {
  if (!timeStr) return "";
  const date = new Date(timeStr);
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, '0');
  const day = String(date.getDate()).padStart(2, '0');
  return `${year}-${month}-${day}`;
};

// 监听 Enter 键发送
const handleKeydown = (e: KeyboardEvent) => {
  if (e.key === "Enter" && !e.shiftKey) {
    e.preventDefault();
    sendMessage();
  }
};

// 点击外部关闭
const handleClickOutside = (e: MouseEvent) => {
  const target = e.target as HTMLElement;
  const chatWindow = document.querySelector(".ai-chat-window");
  const chatButton = document.querySelector(".ai-chat-button");

  if (
    isOpen.value &&
    chatWindow &&
    !chatWindow.contains(target) &&
    chatButton &&
    !chatButton.contains(target)
  ) {
    closeChat();
  }
};

onMounted(() => {
  document.addEventListener("click", handleClickOutside);
});
</script>

<template>
  <div class="ai-chat-container">
    <!-- 悬浮按钮 -->
    <div class="ai-chat-trigger" @click.stop="toggleChat">
      <!-- 提示气泡(在耳朵上方) -->
      <div v-if="!isOpen" class="ai-tooltip">
        <span>点击咨询AI</span>
        <div class="tooltip-arrow"></div>
      </div>
      <!-- 按钮主体 -->
      <div class="ai-chat-button" :class="{ active: isOpen }">
        <img v-if="!isOpen" :src="aiLogo" alt="AI助手" class="ai-icon" />
        <span v-else class="close-icon">×</span>
      </div>
    </div>

    <!-- 聊天窗口 -->
    <transition name="chat-fade">
      <div v-show="isOpen" class="ai-chat-window" @click.stop>
        <!-- 头部 -->
        <div class="chat-header">
          <div class="header-left">
            <img :src="aiLogo" alt="AI" class="header-logo" />
            <div class="header-title">
              <span class="title-text">Hi, 我是 ROYPOW AI 助手</span>
            </div>
          </div>
          <div class="header-actions">
            <button class="action-btn" @click="showHistory = !showHistory" title="历史记录">
              <svg viewBox="0 0 24 24" width="18" height="18">
                <path fill="currentColor"
                  d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z" />
              </svg>
            </button>
            <button class="action-btn" @click="createNewChat" title="新会话">
              <svg viewBox="0 0 24 24" width="18" height="18">
                <path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
              </svg>
            </button>
            <button class="action-btn close-btn" @click="closeChat" title="关闭">
              <span>×</span>
            </button>
          </div>
        </div>

        <!-- 历史会话侧边栏 -->
        <div v-show="showHistory" class="history-sidebar">
          <div class="history-header">
            <span>历史会话</span>
            <button class="new-chat-btn" @click="createNewChat">
              <span>+</span> 新会话
            </button>
          </div>
          <div class="history-list">
            <div v-for="conv in conversations" :key="conv.conversationId" class="history-item"
              :class="{ active: conv.conversationId === currentConversationId }"
              @click="loadHistory(conv.conversationId)">
              <div class="history-title">{{ conv.title || "新会话" }}</div>
              <div class="history-preview">{{ conv.lastMessagePreview }}</div>
              <div class="history-time">{{ formatDate(conv.lastMessageTime) }}</div>
            </div>
            <div v-if="conversations.length === 0" class="history-empty">
              暂无历史会话
            </div>
          </div>
        </div>

        <!-- 消息区域 -->
        <div ref="messagesContainerRef" class="messages-container">
          <!-- 欢迎消息 -->
          <div v-if="messages.length === 0" class="welcome-section">
            <div class="welcome-message">
              <img :src="aiLogo" alt="AI" class="welcome-avatar" />
              <div class="welcome-content">
                <div class="welcome-title">Hi, 我是 ROYPOW AI 助手</div>
                <div class="welcome-desc">您可以问我:</div>
                <ul class="sample-questions">
                  <li v-for="(question, index) in sampleQuestions" :key="index" @click="sendSampleQuestion(question)">
                    {{ question }}
                  </li>
                </ul>
              </div>
            </div>
          </div>

          <!-- 消息列表 -->
          <div v-else class="message-list">
            <div v-for="(msg, index) in messages" :key="msg.messageId || index" class="message-item">
              <!-- 用户消息 -->
              <div class="user-message">
                <div class="message-content-wrapper">
                  <div class="message-bubble user-bubble">
                    {{ msg.query }}
                  </div>
                  <div class="message-time">{{ formatTime(msg.createdAt) }}</div>
                </div>
              </div>

              <!-- AI 回复 -->
              <div class="ai-message">
                <img :src="aiLogo" alt="AI" class="ai-avatar" />
                <div class="message-content-wrapper">
                  <div class="message-bubble ai-bubble">
                    <div v-if="!msg.answer && !msg.thinking && isLoading && index === messages.length - 1"
                      class="typing-indicator">
                      <span></span>
                      <span></span>
                      <span></span>
                    </div>
                    <template v-else>
                      <!-- 思考过程(流式对话时显示,可折叠) -->
                      <div v-if="msg.thinking" class="thinking-content">
                        <div class="thinking-header" @click="toggleThinking(msg)">
                          <span class="thinking-label">💭 思考过程</span>
                          <span class="thinking-toggle">{{ msg.thinkingExpanded ? '收起 ▲' : '展开 ▼' }}</span>
                        </div>
                        <div v-show="msg.thinkingExpanded !== false" class="thinking-text">{{ msg.thinking }}</div>
                      </div>
                      <div class="answer-content">{{ msg.answer }}</div>
                    </template>
                  </div>
                  <div v-if="msg.answer" class="message-actions">
                    <span class="message-time">{{ formatTime(msg.createdAt) }}</span>
                    <div class="feedback-buttons">
                      <button class="feedback-btn" :class="{ active: msg.feedback === 1 }"
                        @click="handleFeedback(msg.messageId, 1)" title="点赞">
                        <svg viewBox="0 0 24 24" width="14" height="14">
                          <path fill="currentColor"
                            d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-1.91l-.01-.01L23 10z" />
                        </svg>
                      </button>
                      <button class="feedback-btn" :class="{ active: msg.feedback === 2 }"
                        @click="handleFeedback(msg.messageId, 2)" title="点踩">
                        <svg viewBox="0 0 24 24" width="14" height="14">
                          <path fill="currentColor"
                            d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v1.91l.01.01L1 14c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z" />
                        </svg>
                      </button>
                    </div>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>

        <!-- 快捷问题 -->
        <div v-if="messages.length > 0" class="quick-questions">
          <button v-for="(question, index) in quickQuestions" :key="index" class="quick-btn"
            @click="sendSampleQuestion(question)">
            {{ question }}
          </button>
        </div>

        <!-- 输入区域 -->
        <div class="input-section">
          <div class="input-wrapper">
            <input v-model="inputMessage" type="text" placeholder="请输入您的问题..." class="chat-input"
              @keydown="handleKeydown" :disabled="isLoading" />
            <button class="send-btn" :disabled="!inputMessage.trim() || isLoading" @click="sendMessage">
              <svg viewBox="0 0 24 24" width="20" height="20">
                <path fill="currentColor" d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
              </svg>
            </button>
          </div>
          <div class="input-footer">基于大语言模型 · ROYPOW AI Lab</div>
        </div>
      </div>
    </transition>
  </div>
</template>

<style lang="scss" scoped>
.ai-chat-container {
  position: fixed;
  right: 24px;
  bottom: 24px;
  z-index: 9999;
}

// 悬浮按钮容器
.ai-chat-trigger {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  cursor: pointer;

  &:hover {
    .ai-chat-button:not(.active) {
      transform: scale(1.08);
    }

    .ai-tooltip {
      opacity: 1;
      transform: translateY(0);
      visibility: visible;
    }
  }
}

// 提示气泡(在耳朵上方)
.ai-tooltip {
  position: absolute;
  top: -50px;
  right: -10px;
  transform: translateY(10px);
  background: #fff;
  padding: 10px 16px;
  border-radius: 8px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
  font-size: 14px;
  color: #333;
  white-space: nowrap;
  opacity: 0;
  visibility: hidden;
  transition: all 0.3s ease;
  z-index: 1;

  span {
    font-size: 14px;
    color: #333;
  }

  .tooltip-arrow {
    position: absolute;
    bottom: -6px;
    right: 35px;
    width: 0;
    height: 0;
    border-left: 6px solid transparent;
    border-right: 6px solid transparent;
    border-top: 6px solid #fff;
  }
}

// 悬浮按钮
.ai-chat-button {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  background: transparent;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: all 0.3s ease;
  overflow: visible;

  &:hover:not(.active) {
    transform: scale(1.08);
  }

  &.active {
    width: 56px;
    height: 56px;
    background: #f5f5f5;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  }

  .ai-icon {
    width: 100%;
    height: 100%;
    object-fit: contain;
    filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.2));
  }

  .close-icon {
    font-size: 32px;
    color: #666;
    line-height: 1;
  }
}

// 聊天窗口
.ai-chat-window {
  position: absolute;
  right: 0;
  bottom: 90px;
  width: 450px;
  height: 750px;
  background: #fff;
  border-radius: 16px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  transform-origin: bottom right;
}

.chat-fade-enter-active,
.chat-fade-leave-active {
  transition: all 0.3s ease;
}

.chat-fade-enter-from,
.chat-fade-leave-to {
  opacity: 0;
  transform: scale(0.9) translateY(10px);
}

// 头部
.chat-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 20px;
  background: linear-gradient(135deg, #00d68f 0%, #00b4d8 100%);
  color: #fff;

  .header-left {
    display: flex;
    align-items: center;
    gap: 12px;
  }

  .header-logo {
    width: 36px;
    height: 36px;
    border-radius: 50%;
    background: #fff;
    padding: 4px;
  }

  .header-title {
    .title-text {
      font-size: 16px;
      font-weight: 600;
    }
  }

  .header-actions {
    display: flex;
    align-items: center;
    gap: 8px;

    .action-btn {
      width: 32px;
      height: 32px;
      border: none;
      background: rgba(255, 255, 255, 0.2);
      border-radius: 8px;
      color: #fff;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: all 0.2s;

      &:hover {
        background: rgba(255, 255, 255, 0.3);
      }

      &.close-btn {
        font-size: 20px;
        line-height: 1;
      }
    }
  }
}

// 历史会话侧边栏
.history-sidebar {
  position: absolute;
  left: 0;
  top: 68px;
  bottom: 0;
  width: 280px;
  background: #fff;
  border-right: 1px solid #e8e8e8;
  z-index: 10;
  display: flex;
  flex-direction: column;

  .history-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px 16px;
    border-bottom: 1px solid #e8e8e8;
    font-weight: 600;
    font-size: 14px;

    .new-chat-btn {
      padding: 6px 12px;
      background: #00d68f;
      color: #fff;
      border: none;
      border-radius: 6px;
      font-size: 12px;
      cursor: pointer;
      display: flex;
      align-items: center;
      gap: 4px;

      &:hover {
        background: #00c17f;
      }
    }
  }

  .history-list {
    flex: 1;
    overflow-y: auto;
    padding: 8px;

    .history-item {
      padding: 12px;
      border-radius: 8px;
      cursor: pointer;
      transition: all 0.2s;
      margin-bottom: 4px;

      &:hover,
      &.active {
        background: #f0f9f6;
      }

      .history-title {
        font-size: 14px;
        font-weight: 500;
        color: #333;
        margin-bottom: 4px;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }

      .history-preview {
        font-size: 12px;
        color: #999;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        margin-bottom: 4px;
      }

      .history-time {
        font-size: 11px;
        color: #bbb;
      }
    }

    .history-empty {
      text-align: center;
      padding: 40px 20px;
      color: #999;
      font-size: 14px;
    }
  }
}

// 消息区域
.messages-container {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  background: #f8fafc;
}

// 欢迎区域
.welcome-section {
  .welcome-message {
    display: flex;
    gap: 12px;
    margin-bottom: 20px;

    .welcome-avatar {
      width: 40px;
      height: 40px;
      border-radius: 50%;
      flex-shrink: 0;
    }

    .welcome-content {
      flex: 1;
      background: #fff;
      padding: 16px;
      border-radius: 12px;
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);

      .welcome-title {
        font-size: 16px;
        font-weight: 600;
        color: #333;
        margin-bottom: 12px;
      }

      .welcome-desc {
        font-size: 14px;
        color: #666;
        margin-bottom: 8px;
      }

      .sample-questions {
        list-style: none;
        padding: 0;
        margin: 0;

        li {
          padding: 8px 12px;
          margin-bottom: 6px;
          background: #f0f9f6;
          border-radius: 8px;
          font-size: 13px;
          color: #00a870;
          cursor: pointer;
          transition: all 0.2s;

          &:hover {
            background: #e0f5ed;
          }

          &::before {
            content: "•";
            margin-right: 8px;
          }
        }
      }
    }
  }
}

// 消息列表
.message-list {
  display: flex;
  flex-direction: column;
  gap: 20px;

  .message-item {
    display: flex;
    flex-direction: column;
    gap: 16px;
  }
}

// 用户消息
.user-message {
  display: flex;
  justify-content: flex-end;

  .message-content-wrapper {
    max-width: 80%;
    text-align: right;
  }

  .user-bubble {
    background: linear-gradient(135deg, #00d68f 0%, #00b4d8 100%);
    color: #fff;
    padding: 12px 16px;
    border-radius: 16px 16px 4px 16px;
    font-size: 14px;
    line-height: 1.5;
    word-break: break-word;
  }
}

// AI 消息
.ai-message {
  display: flex;
  gap: 12px;

  .ai-avatar {
    width: 36px;
    height: 36px;
    border-radius: 50%;
    flex-shrink: 0;
  }

  .message-content-wrapper {
    flex: 1;
    max-width: calc(100% - 48px);
  }

  .ai-bubble {
    background: #fff;
    padding: 14px 16px;
    border-radius: 4px 16px 16px 16px;
    font-size: 14px;
    line-height: 1.6;
    color: #333;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
    word-break: break-word;

    .thinking-content {
      margin-bottom: 12px;
      border-radius: 8px;
      border-left: 3px solid #00b4d8;
      overflow: hidden;

      .thinking-header {
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 8px 12px;
        background: #f5f5f5;
        cursor: pointer;
        transition: all 0.2s;
        user-select: none;

        &:hover {
          background: #e8e8e8;
        }

        .thinking-label {
          font-size: 12px;
          color: #666;
          font-weight: 500;
        }

        .thinking-toggle {
          font-size: 12px;
          color: #999;
          transition: transform 0.3s;
        }
      }

      .thinking-text {
        padding: 12px;
        background: #fafafa;
        font-size: 13px;
        color: #888;
        white-space: pre-wrap;
        font-style: italic;
        line-height: 1.6;
      }
    }

    .answer-content {
      white-space: pre-wrap;
    }
  }
}

// 输入中动画
.typing-indicator {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 4px 0;

  span {
    width: 8px;
    height: 8px;
    background: #ccc;
    border-radius: 50%;
    animation: typing 1.4s infinite;

    &:nth-child(2) {
      animation-delay: 0.2s;
    }

    &:nth-child(3) {
      animation-delay: 0.4s;
    }
  }
}

@keyframes typing {

  0%,
  60%,
  100% {
    transform: translateY(0);
    opacity: 0.5;
  }

  30% {
    transform: translateY(-8px);
    opacity: 1;
  }
}

// 消息时间和操作
.message-time {
  font-size: 11px;
  color: #999;
  margin-top: 4px;
}

.message-actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-top: 4px;

  .feedback-buttons {
    display: flex;
    gap: 8px;

    .feedback-btn {
      width: 28px;
      height: 28px;
      border: none;
      background: transparent;
      border-radius: 6px;
      color: #999;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: all 0.2s;

      &:hover {
        background: #f0f0f0;
        color: #666;
      }

      &.active {
        color: #00d68f;
        background: #e0f5ed;
      }
    }
  }
}

// 快捷问题
.quick-questions {
  display: flex;
  flex-wrap: nowrap;
  gap: 8px;
  padding: 12px 20px;
  background: #fff;
  border-top: 1px solid #e8e8e8;
  overflow-x: auto;
  white-space: nowrap;

  // 隐藏滚动条但保留功能
  &::-webkit-scrollbar {
    height: 4px;
  }

  &::-webkit-scrollbar-thumb {
    background: #ddd;
    border-radius: 2px;
  }

  &::-webkit-scrollbar-track {
    background: transparent;
  }

  .quick-btn {
    padding: 6px 12px;
    background: #f0f9f6;
    border: 1px solid #d0ebe3;
    border-radius: 16px;
    font-size: 12px;
    color: #00a870;
    cursor: pointer;
    white-space: nowrap;
    flex-shrink: 0;
    transition: all 0.2s;

    &:hover {
      background: #e0f5ed;
      border-color: #00d68f;
    }
  }
}

// 输入区域
.input-section {
  padding: 16px 20px;
  background: #fff;
  border-top: 1px solid #e8e8e8;

  .input-wrapper {
    display: flex;
    gap: 12px;
    align-items: center;
    background: #f5f5f5;
    border-radius: 24px;
    padding: 4px 4px 4px 16px;

    .chat-input {
      flex: 1;
      border: none;
      background: transparent;
      font-size: 14px;
      color: #333;
      outline: none;
      padding: 8px 0;

      &::placeholder {
        color: #999;
      }

      &:disabled {
        opacity: 0.6;
        cursor: not-allowed;
      }
    }

    .send-btn {
      width: 36px;
      height: 36px;
      border: none;
      background: #00d68f;
      color: #fff;
      border-radius: 50%;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: all 0.2s;

      &:hover:not(:disabled) {
        background: #00c17f;
        transform: scale(1.05);
      }

      &:disabled {
        background: #ccc;
        cursor: not-allowed;
      }
    }
  }

  .input-footer {
    text-align: center;
    font-size: 11px;
    color: #bbb;
    margin-top: 8px;
  }
}

// 滚动条样式
.messages-container::-webkit-scrollbar,
.history-list::-webkit-scrollbar {
  width: 6px;
}

.messages-container::-webkit-scrollbar-thumb,
.history-list::-webkit-scrollbar-thumb {
  background: #ddd;
  border-radius: 3px;
}

.messages-container::-webkit-scrollbar-track,
.history-list::-webkit-scrollbar-track {
  background: transparent;
}

// 响应式
@media (max-width: 480px) {
  .ai-chat-container {
    right: 12px;
    bottom: 12px;
  }

  .ai-chat-button {
    width: 64px;
    height: 64px;

    &.active {
      width: 48px;
      height: 48px;
    }
  }

  .ai-tooltip {
    display: none;
  }

  .ai-chat-window {
    width: calc(100vw - 24px);
    height: 70vh;
    right: 0;
    bottom: 72px;
  }

  .ai-tooltip {
    display: none;
  }

  .history-sidebar {
    width: 100%;
  }
}
</style>

2.aiChat.ts

javascript 复制代码
import { http } from "@/utils/http";
import { baseUrlApi } from "./utils";
import { CommonResult } from "./common";
import { getAuthParam } from "@/utils/auth";

/**
 * AI 聊天请求参数
 */
export interface ChatRequest {
  /** 用户问题内容 */
  query: string;
  /** 会话 ID(可选,为空时创建新会话) */
  conversationId?: string;
  /** 设备序列号(可选,用于提供上下文) */
  deviceSn?: string;
  /** 电池 ID(可选,用于提供上下文) */
  batteryId?: number;
  /** 输入参数(用于传递设备状态等变量) */
  inputs?: Record<string, any>;
  /** 文件 ID 列表(用于图片、文件对话) */
  fileIds?: string[];
  /** 响应模式:streaming-流式,blocking-阻塞 */
  responseMode?: "streaming" | "blocking";
}

/**
 * AI 聊天响应
 */
export interface ChatResponse {
  /** 会话 ID */
  conversationId: string;
  /** 消息 ID */
  messageId: string;
  /** AI 回答内容 */
  answer: string;
  /** 响应延迟(毫秒) */
  latencyMs: number;
}

/**
 * 聊天消息 VO
 */
export interface ChatMessageVO {
  /** 消息 ID */
  messageId: string;
  /** 用户问题 */
  query: string;
  /** AI 回答 */
  answer: string;
  /** AI 思考过程(流式对话时显示) */
  thinking?: string;
  /** 思考过程是否展开(用于 UI 控制) */
  thinkingExpanded?: boolean;
  /** Token 总数 */
  totalTokens: number;
  /** 延迟(毫秒) */
  latencyMs: number;
  /** 反馈:1-点赞,2-点踩 */
  feedback?: number;
  /** 创建时间 */
  createdAt: string;
}

/**
 * 会话 VO
 */
export interface ConversationVO {
  /** 会话 ID */
  conversationId: string;
  /** 会话标题 */
  title: string;
  /** 最后一条消息预览 */
  lastMessagePreview: string;
  /** 最后一条消息时间 */
  lastMessageTime: string;
  /** 消息数量 */
  messageCount: number;
  /** 创建时间 */
  createdAt: string;
}

/**
 * 反馈请求
 */
export interface FeedbackRequest {
  /** 消息 ID */
  messageId: string;
  /** 反馈类型:1-点赞,2-点踩 */
  feedback: number;
  /** 反馈原因(点踩时必填) */
  reason?: string;
}

/**
 * 分页响应
 */
export interface PageResDto<T> {
  data: T[];
  total: number;
  pageNum: number;
  pageSize: number;
}

/**
 * 阻塞式对话(简单问答)
 * POST /aiChat/chat
 */
export function chatBlocking(data: ChatRequest): Promise<CommonResult> {
  return http.postJson<ChatRequest, CommonResult>(
    baseUrlApi("aiChat/chat"),
    data
  );
}

/**
 * 流式对话回调函数类型
 */
export interface StreamChatCallbacks {
  onMessage?: (data: any) => void;
  onEnd?: (data: any) => void;
  onError?: (error: any) => void;
}

/**
 * 流式对话(SSE)- POST 方式
 * POST /aiChat/stream
 * 
 * 使用 fetch + ReadableStream 实现,支持 POST 请求和自定义请求头
 */
export async function streamChat(
  query: string,
  conversationId?: string,
  deviceSn?: string,
  batteryId?: number,
  callbacks?: StreamChatCallbacks
): Promise<{ close: () => void }> {
  // 构建请求体(与阻塞式接口一致,通过 body 传递参数)
  const requestBody: Record<string, any> = {
    query
  };

  // 添加可选参数
  if (conversationId) requestBody.conversationId = conversationId;
  if (deviceSn) requestBody.deviceSn = deviceSn;
  if (batteryId) requestBody.batteryId = batteryId;

  // 使用 getAuthParam 添加认证参数
  const authData = getAuthParam(requestBody);

  const controller = new AbortController();
  const { signal } = controller;

  try {
    console.log("[SSE] 开始请求...");
    const response = await fetch(baseUrlApi("aiChat/stream"), {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Token": authData.Authorization || ""
      },
      body: JSON.stringify(authData),
      signal
    });

    console.log("[SSE] 响应状态:", response.status);

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    if (!response.body) {
      throw new Error("Response body is null");
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = "";

    // 标记是否已触发结束回调
    let hasEnded = false;

    // 开始读取流
    const readStream = async () => {
      try {
        console.log("[SSE] 开始读取流...");
        let readCount = 0;
        while (true) {
          const { done, value } = await reader.read();
          readCount++;
          console.log("[SSE] 第", readCount, "次读取 - done:", done, ", value:", value ? "长度=" + value.length : "null");
          
          if (done) {
            console.log("[SSE] 流结束,总共读取了", readCount, "次");
            break;
          }

          const decodedText = decoder.decode(value, { stream: true });
          console.log("[SSE] 解码后的文本 (前 200 字符):", decodedText.substring(0, 200));
          console.log("[SSE] 解码后的完整文本:", decodedText);
          
          buffer += decodedText;
          console.log("[SSE] 累积 buffer 长度:", buffer.length);
          
          // 按行处理,累积完整的消息
          const lines = buffer.split("\n");
          console.log("[SSE] 分割后的行数:", lines.length);
          
          // 保留最后一个不完整的行到 buffer
          buffer = lines.pop() || "";
          console.log("[SSE] 保留到 buffer 的剩余内容:", buffer.substring(0, 100));
          
          for (const line of lines) {
            const trimmed = line.trim();
            if (!trimmed) {
              console.log("[SSE] 跳过空行");
              continue; // 跳过空行
            }
            
            console.log("[SSE] 处理行:", trimmed.substring(0, 100));
            
            // SSE 标准格式:event:xxx 或 data:xxx 或 id:xxx
            // 我们只关心 data: 开头的行
            if (trimmed.startsWith("data:")) {
              // 去除 "data:" 前缀,并处理可能的空格(data: 或 data: )
              let jsonStr = trimmed.substring(5).trim();
              // 如果还有前导空格,继续去除
              if (jsonStr.startsWith(":")) {
                jsonStr = jsonStr.substring(1).trim();
              }
              console.log("[SSE] 提取 data: 后的 JSON (前 100 字符):", jsonStr.substring(0, 100));
              
              // 只处理 JSON 对象(以 { 开头)
              if (!jsonStr.startsWith("{")) {
                console.log("[SSE] 跳过非 JSON 数据:", jsonStr);
                continue;
              }
              
              try {
                const data = JSON.parse(jsonStr);
                console.log("[SSE] 解析成功:", data);
                console.log("[SSE] event:", data.event, "| answer:", data.answer, "| status:", data.status);
                
                // 判断消息类型
                if (data.event === "message" && callbacks?.onMessage) {
                  console.log("[SSE] 调用 onMessage 回调,answer 内容:", data.answer);
                  hasEnded = false;
                  callbacks.onMessage(data);
                } else if (data.event === "end" && callbacks?.onEnd) {
                  console.log("[SSE] 调用 onEnd 回调 (event=end)");
                  hasEnded = true;
                  callbacks.onEnd(data);
                } else if (data.status === "completed" && callbacks?.onEnd) {
                  console.log("[SSE] 调用 onEnd 回调 (status=completed)");
                  hasEnded = true;
                  callbacks.onEnd(data);
                } else {
                  console.log("[SSE] 未知的事件类型或没有回调:", data.event, data.status);
                }
              } catch (e) {
                console.error("[SSE] JSON 解析失败:", e, "原始数据:", jsonStr);
              }
            } else if (trimmed.startsWith("event:") || trimmed.startsWith("id:")) {
              // 跳过 event: 和 id: 行,这些是 SSE 元数据
              console.log("[SSE] 跳过 SSE 元数据行:", trimmed);
            } else {
              console.log("[SSE] 跳过未知格式的行:", trimmed);
            }
          }
        }
        
        // 处理最后可能剩余的数据
        const remaining = buffer.trim();
        if (remaining && remaining.startsWith("{")) {
          try {
            const data = JSON.parse(remaining);
            console.log("[SSE] 最后数据解析成功:", data);
            if (data.event === "message" && callbacks?.onMessage) {
              callbacks.onMessage(data);
            } else if ((data.event === "end" || data.status === "completed") && callbacks?.onEnd) {
              hasEnded = true;
              callbacks.onEnd(data);
            }
          } catch (e) {
            console.error("[SSE] 最后数据解析失败:", e);
          }
        }
        
        // 确保流结束时触发结束回调
        if (!hasEnded && callbacks?.onEnd) {
          hasEnded = true;
          console.log("[SSE] 流正常结束,触发 onEnd");
          callbacks.onEnd({ status: "completed" });
        }
      } catch (error) {
        console.error("[SSE] 读取流错误:", error);
        if ((error as Error).name !== "AbortError" && callbacks?.onError) {
          callbacks.onError(error);
        }
      }
    };

    // 启动读取
    readStream();

  } catch (error) {
    console.error("[SSE] 请求失败:", error);
    if ((error as Error).name !== "AbortError" && callbacks?.onError) {
      callbacks.onError(error);
    }
  }

  return {
    close: () => controller.abort()
  };
}

/**
 * 获取历史聊天记录(分页)
 * POST /aiChat/history
 */
export function getChatHistory(
  data: {
    conversationId?: string;
    lastMessageId?: string;
    limit?: number;
  } = {}
): Promise<CommonResult> {
  return http.request<CommonResult>("post", baseUrlApi("aiChat/history"), {
    data
  });
}

/**
 * 获取用户的会话列表
 * POST /aiChat/conversations
 */
export function getConversations(
  data: {
    limit?: number;
    deviceSn?: string;
    batteryId?: number;
  } = {}
): Promise<CommonResult> {
  return http.request<CommonResult>("post", baseUrlApi("aiChat/conversations"), {
    data
  });
}

/**
 * 提交消息反馈(点赞/点踩)
 * POST /aiChat/feedback
 */
export function submitFeedback(
  data: FeedbackRequest
): Promise<CommonResult> {
  return http.postJson<FeedbackRequest, CommonResult>(
    baseUrlApi("aiChat/feedback"),
    data
  );
}

二.后端代码

java 复制代码
package com.roypowtech.api.controller.smartanalysis;

import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.roypowtech.api.dto.common.PageResDto;
import com.roypowtech.api.dto.smartanalysis.aichat.ChatRequest;
import com.roypowtech.api.dto.smartanalysis.aichat.ChatResponse;
import com.roypowtech.api.dto.smartanalysis.aichat.FeedbackRequest;
import com.roypowtech.api.global.annotation.SysOperLogAnnotation;
import com.roypowtech.api.global.enums.ResultCode;
import com.roypowtech.api.global.result.Result;
import com.roypowtech.api.service.smartanalysis.AiChatService;
import com.roypowtech.api.utils.SysUtil;
import com.roypowtech.api.vo.smartanalysis.ChatMessageVO;
import com.roypowtech.api.vo.smartanalysis.ConversationVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;

import javax.validation.Valid;
import java.util.HashMap;
import java.util.List;
import java.util.Map;


/**
 * AI客服聊天控制器 - 与前端交互的接口层
 * 实际业务逻辑由smartanalysis服务处理
 */
@Slf4j
@RestController
@RequestMapping("aiChat")
@RequiredArgsConstructor
public class AiChatController {

    private final AiChatService aiChatService;

    /**
     * 流式对话(SSE)
     * <p>
     * 接口路径:GET /aiChat/stream
     * 请求参数:
     * - query: 用户问题内容 (必填)
     * - conversationId: 会话 ID (可选,为空时创建新会话)
     * - deviceSn: 设备序列号 (可选,用于提供上下文)
     * - batteryId: 电池 ID (可选,用于提供上下文)
     * 返回数据:Flux<ServerSentEvent<String>> - SSE 流式响应
     * 示例:
     * event: message
     * data: {"message_id":"xxx","answer":"片段 1"}
     * <p>
     * event: end
     * data: {"status":"completed"}
     *
     * @param request 聊天请求对象
     * @return SSE 流式响应
     */
    @SysOperLogAnnotation(moudle = "智能分析", action = "AI 流式对话")
    @PostMapping(value = "/stream", produces = "text/event-stream;charset=UTF-8")
    public Flux<ServerSentEvent<JSONObject>> streamChat(@RequestBody ChatRequest request) {;

        try {
            return aiChatService.chatStream(request, SysUtil.getUserId()).map(sse -> {
                String data = sse.data();
                JSONObject jsonObject = JSONUtil.parseObj(data);
                return ServerSentEvent.<JSONObject>builder()
                        .id(sse.id())
                        .event(sse.event())
                        .data(jsonObject)
                        .build();
            });
        } catch (Exception e) {
            log.error("AI 流式对话失败 - query: {}", JSONUtil.toJsonStr(request), e);
            throw e;
        }
    }

    /**
     * 阻塞式对话(简单问答)
     * 
     * 接口路径:POST /aiChat/chat
     * 请求参数:ChatRequest - 聊天请求对象
     *   - query: 用户问题内容 (必填)
     *   - conversationId: 会话 ID (可选)
     *   - deviceSn: 设备序列号 (可选)
     *   - batteryId: 电池 ID (可选)
     *   - inputs: 输入参数 (可选,用于传递设备状态等变量)
     * 返回数据:ChatResponse - 聊天响应对象
     * 示例:
     *  {
     *      "conversationId": "conv_abc123",
     *      "messageId": "msg_xyz789",
     *      "answer": "SOH 衰减可能与以下因素有关...",
     *      "latencyMs": 1250
     *  }
     * 
     * @param request 聊天请求对象
     * @return 聊天响应结果
     */
    @SysOperLogAnnotation(moudle = "智能分析", action = "AI 阻塞式对话")
    @PostMapping("/chat")
    public Result chat(@RequestBody @Valid ChatRequest request) {
        try {
            ChatResponse response = aiChatService.chatBlocking(request, SysUtil.getUserId());
            return Result.success(response);
        } catch (Exception e) {
            log.error("AI 阻塞式对话失败 - query: {}", request.getQuery(), e);
            return Result.failure(ResultCode.SYSTEM_ERROR.getCode(),"AI 对话失败:" + e.getMessage());
        }
    }

    /**
     * 获取历史聊天记录(分页)
     * 
     * 接口路径:GET /aiChat/history
     * 请求参数:
     *   - conversationId: 会话 ID (可选)
     *   - lastMessageId: 最后一条消息 ID (可选,用于分页)
     *   - limit: 每页数量,默认 20
     * 返回数据:PageResDto<ChatMessageVO> - 分页消息列表
     * 示例:
     *  {
     *      "data": [
     *          {
     *              "messageId": "msg_001",
     *              "query": "SOH 是什么?",
     *              "answer": "SOH 是电池健康度...",
     *              "totalTokens": 150,
     *              "latencyMs": 800,
     *              "feedback": 1,
     *              "createdAt": "2026-03-19T10:30:00"
     *          }
     *      ],
     *      "total": 100,
     *      "pageNum": 1,
     *      "pageSize": 20
     *  }
     * @param  request
     *  conversationId 会话 ID (可选)
     *  lastMessageId 最后一条消息 ID (可选)
     *  limit 每页数量 (默认 20)
     * @return 分页消息列表
     */
    @SysOperLogAnnotation(moudle = "智能分析", action = "获取历史聊天记录")
    @PostMapping("/history")
    public Result getHistory(@Valid ChatRequest request ) {
        String conversationId = request.getConversationId();
        try {
            PageResDto<ChatMessageVO> result = aiChatService.getChatHistory(request,SysUtil.getUserId());
            return Result.success(result);
        } catch (Exception e) {
            log.error("获取历史聊天记录失败 - conversationId: {}", conversationId, e);
            return Result.failure(ResultCode.SYSTEM_ERROR.getCode(),"获取历史记录失败:" + e.getMessage());
        }
    }

    /**
     * 获取用户的会话列表
     * 
     * 接口路径:GET /aiChat/conversations
     * 请求参数:
     *   - limit: 每页数量,默认 20
     * 返回数据:List<ConversationVO> - 会话列表
     * 示例:
     *  [
     *      {
     *          "conversationId": "conv_abc123",
     *          "title": "关于电池 SOH 的咨询",
     *          "lastMessagePreview": "SOH 衰减太快怎么办?",
     *          "lastMessageTime": "2026-03-19T10:30:00",
     *          "messageCount": 15,
     *          "createdAt": "2026-03-18T09:00:00"
     *      }
     *  ]
     * 
     * @param limit 每页数量 (默认 20)
     * @return 会话列表
     */
    @SysOperLogAnnotation(moudle = "智能分析", action = "获取会话列表")
    @PostMapping("/conversations")
    public Result getConversations(
            @RequestParam(defaultValue = "20") Integer limit) {

        try {
            List<ConversationVO> conversations = aiChatService.getUserConversations(SysUtil.getUserId(), limit);
            return Result.success(conversations);
        } catch (Exception e) {
            log.error("获取会话列表失败 - limit: {}", limit, e);
            return Result.failure(ResultCode.SYSTEM_ERROR.getCode(),"获取会话列表失败:" + e.getMessage());
        }
    }

    /**
     * 提交消息反馈(点赞/点踩)
     * 
     * 接口路径:POST /aiChat/feedback
     * 请求参数:FeedbackRequest - 反馈请求对象
     *   - messageId: 消息 ID (必填)
     *   - feedback: 反馈类型 (必填,1-点赞,2-点踩)
     *   - reason: 反馈原因 (点踩时必填)
     * 返回数据:无
     * 示例:
     *  {
     *      "code": 200,
     *      "msg": "Success"
     *  }
     * 
     * @param request 反馈请求对象
     * @return 操作结果
     */
    @SysOperLogAnnotation(moudle = "智能分析", action = "提交消息反馈")
    @PostMapping("/feedback")
    public Result submitFeedback(@RequestBody @Valid FeedbackRequest request) {
        try {
            aiChatService.submitFeedback(request.getMessageId(), request.getFeedback(), request.getReason(), SysUtil.getUserId());
            return Result.success();
        } catch (Exception e) {
            log.error("提交消息反馈失败 - messageId: {}, feedback: {}", request.getMessageId(), request.getFeedback(), e);
            return Result.failure(ResultCode.SYSTEM_ERROR.getCode(),"提交反馈失败:" + e.getMessage());
        }
    }


    private Map<String, Object> buildDeviceContext(String deviceSn, Long batteryId) {
        Map<String, Object> context = new HashMap<>();
        if (StrUtil.isNotBlank(deviceSn)) {
            context.put("device_sn", deviceSn);
            // 可查询设备实时数据注入上下文
            // context.put("current_soc", getCurrentSoc(deviceSn));
            // context.put("current_soh", getCurrentSoh(deviceSn));
        }
        return context;
    }
}
java 复制代码
package com.roypowtech.api.service.smartanalysis.impl;

import cn.hutool.core.lang.TypeReference;
import cn.hutool.json.JSONUtil;
import com.roypowtech.api.dto.common.PageResDto;
import com.roypowtech.api.dto.smartanalysis.aichat.ChatRequest;
import com.roypowtech.api.dto.smartanalysis.aichat.ChatResponse;
import com.roypowtech.api.grpc.SmartAnalysisGrpcBaseService;
import com.roypowtech.api.service.smartanalysis.AiChatService;
import com.roypowtech.api.vo.smartanalysis.ChatMessageVO;
import com.roypowtech.api.vo.smartanalysis.ConversationVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;

import java.util.List;

/**
 * AI 聊天服务实现类 (API 模块)
 * 通过 gRPC 调用 smartanalysis 服务
 *
 * @author RoyPowTech
 * @date 2026-03-19
 */
@Slf4j
@Service
public class AiChatServiceImpl extends SmartAnalysisGrpcBaseService implements AiChatService {

    @Override
    public Flux<ServerSentEvent<String>> chatStream(ChatRequest request, String userId) {
        log.info("AI 流式对话 - userId: {}, query: {}", userId, request.getQuery());
        
        // 通过 gRPC 流式 RPC 调用 smartanalysis 服务(真正的流式响应)
        // 关键优化:使用 publishOn 切换到弹性调度器,避免主线程阻塞导致延迟
        return executeStream("chatStream", request, userId, null, null)
                .publishOn(Schedulers.boundedElastic()) // 切换到弹性调度器处理 SSE 发送
                .map(sse -> {
                    try {
                        // gRPC 返回的 data 已经是 JSON 字符串,这里需要确保编码正确
                        // 直接使用原始数据,因为已经在 smart-analysis 层处理过编码
                        return ServerSentEvent.<String>builder()
                                .id(sse.id())
                                .event(sse.event())
                                .data(sse.data()) // 这里的 data 已经是正确的 JSON 字符串
                                .build();
                    } catch (Exception e) {
                        log.error("SSE 数据转换失败 - id: {}, event: {}", sse.id(), sse.event(), e);
                        // 返回错误事件给前端
                        return ServerSentEvent.<String>builder()
                                .event("error")
                                .data("{\"error\":\"数据转换失败\"}")
                                .build();
                    }
                })
                .doOnSubscribe(subscription -> 
                    log.info("SSE 流开始订阅 - query: {}", request.getQuery()))
                .doOnComplete(() -> 
                    log.info("SSE 流完成 - query: {}", request.getQuery()));
    }

    @Override
    public ChatResponse chatBlocking(ChatRequest request, String userId) {
        log.info("AI 阻塞对话 - userId: {}, query: {}", userId, request.getQuery());
        // 通过 gRPC 调用 smartanalysis 服务
        return execute("chatBlocking", request, userId, null, null,
                new TypeReference<ChatResponse>() {});
    }

    @Override
    public PageResDto<ChatMessageVO> getChatHistory(ChatRequest request, String userId) {
        String conversationId = request.getConversationId();
        String lastMessageId = request.getLastMessageId();
        Integer limit = request.getLimit();
        log.info("获取历史消息 - userId: {}, conversationId: {}", userId, conversationId);
        // 通过 gRPC 调用 smartanalysis 服务
        return execute("getChatHistory", JSONUtil.toJsonStr(request), userId, null, null,
                new TypeReference<PageResDto<ChatMessageVO>>() {});
    }

    @Override
    public List<ConversationVO> getUserConversations(String userId, Integer limit) {
        log.info("获取会话列表 - userId: {}, limit: {}", userId, limit);
        
        // 通过 gRPC 调用 smartanalysis 服务
        return execute("getUserConversations", null, userId, limit != null ? limit.toString() : null,null,
                new TypeReference<List<ConversationVO>>() {});
    }

    @Override
    public void submitFeedback(String messageId, Integer feedback, String reason, String userId) {
        log.info("提交反馈 - userId: {}, messageId: {}, feedback: {}", userId, messageId, feedback);
        
        // 构建反馈数据
        String data = String.format("{\"messageId\":\"%s\",\"feedback\":%d,\"reason\":\"%s\"}", 
                messageId, feedback != null ? feedback : 0, reason != null ? reason : "");
        
        // 通过 gRPC 调用 smartanalysis 服务
        executeVoid("submitFeedback", data, null, userId.toString(), null);
    }
}

package com.roypowtech.api.service.smartanalysis;

import com.roypowtech.api.dto.common.PageResDto;
import com.roypowtech.api.dto.smartanalysis.aichat.ChatRequest;
import com.roypowtech.api.dto.smartanalysis.aichat.ChatResponse;
import com.roypowtech.api.vo.smartanalysis.ChatMessageVO;
import com.roypowtech.api.vo.smartanalysis.ConversationVO;
import org.springframework.http.codec.ServerSentEvent;
import reactor.core.publisher.Flux;

import java.util.List;

/**
 * AI 聊天服务接口 (API 模块)
 *
 * @author RoyPowTech
 * @date 2026-03-19
 */
public interface AiChatService {

    /**
     * 流式对话
     *
     * @param request 请求参数
     * @param userId  用户 ID
     * @return SSE 流
     */
    Flux<ServerSentEvent<String>> chatStream(ChatRequest request, String userId);

    /**
     * 阻塞式对话
     *
     * @param request 请求参数
     * @param userId 用户 ID
     * @return 响应结果
     */
    ChatResponse chatBlocking(ChatRequest request, String userId);

    /**
     * 获取历史聊天记录
     *
     * @param userId 用户 ID
     * @param request 请求参数
     * @return 分页结果
     */
    PageResDto<ChatMessageVO> getChatHistory(ChatRequest request, String userId);

    /**
     * 获取用户的会话列表
     *
     * @param userId 用户 ID
     * @param limit 限制数量
     * @return 会话列表
     */
    List<ConversationVO> getUserConversations(String userId, Integer limit);

    /**
     * 提交反馈
     *
     * @param messageId 消息 ID
     * @param feedback 反馈类型
     * @param reason 反馈原因
     * @param userId 用户 ID
     */
    void submitFeedback(String messageId, Integer feedback, String reason, String userId);
}

AI处理:

java 复制代码
package com.roypowtech.smartanalysis.service.impl;

import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.alibaba.fastjson2.JSON;
import com.roypowtech.smartanalysis.common.PageResult;
import com.roypowtech.smartanalysis.constants.SmartAnalysisRedisConstants;
import com.roypowtech.smartanalysis.dto.aichat.*;
import com.roypowtech.smartanalysis.entity.AiChatMessage;
import com.roypowtech.smartanalysis.entity.AiConversation;
import com.roypowtech.smartanalysis.mapper.AiChatMessageMapper;
import com.roypowtech.smartanalysis.mapper.AiConversationMapper;
import com.roypowtech.smartanalysis.service.AiChatService;
import com.roypowtech.smartanalysis.service.ChatAnalyticsService;
import com.roypowtech.smartanalysis.utils.DifyApiClient;
import com.roypowtech.smartanalysis.utils.RedisUtil;
import com.roypowtech.smartanalysis.vo.ChatMessageVO;
import com.roypowtech.smartanalysis.vo.ConversationVO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

/**
 * AI 聊天业务服务实现
 * 处理聊天记录持久化、会话管理、统计分析
 */
@Service
@Slf4j
public class AiChatServiceImpl implements AiChatService {

    @Autowired
    private DifyApiClient difyApiClient;
    @Autowired
    private AiConversationMapper conversationMapper;
    @Autowired
    private AiChatMessageMapper messageMapper;
    @Autowired
    private ChatAnalyticsService analyticsService;
    @Autowired
    private RedisUtil redisUtil;

    /**
     * 发送消息(流式)
     * 使用 SSE 返回,同时异步保存到数据库
     */
    @Override
    public Flux<ServerSentEvent<String>> chatStream(ChatRequest request, Long userId) {
        // 1. 判断是否为首次对话(没有 conversationId 时)
        boolean isFirstChat = StringUtils.isBlank(request.getConversationId());
        
        // 2. 如果是首次对话,先创建临时会话记录(不带 conversationId)
        final String[] tempConversationIdRef = new String[]{null};
        if (isFirstChat) {
            tempConversationIdRef[0] = createTempConversation(userId, request.getDeviceSn(), 
                    request.getBatteryId(), request.getQuery());
        }
        
        // 3. 构建 Dify 请求(首次对话时 conversationId 为 null)
        String finalConversationId = isFirstChat ? null : request.getConversationId();
        DifyChatRequest difyRequest = buildDifyRequest(request, userId, finalConversationId);

        // 4. 预保存用户消息(用于后续关联)
        String tempMessageId = "temp_" + IdUtil.fastUUID();

        // 5. 流式调用并处理
        StringBuilder fullAnswer = new StringBuilder();
        AtomicInteger tokenCount = new AtomicInteger(0);
        long startTime = System.currentTimeMillis();

        return difyApiClient.sendMessageStreaming(difyRequest)
                .flatMap(chunk -> {
                    try {
                        // chunk 可能是普通字符串或 JSON 字符串,需要过滤非 data 行
                        String trimmed = chunk.trim();
                        
                        // 跳过非 JSON 格式的行(如空行、注释等)
                        if (!trimmed.startsWith("{") && !trimmed.startsWith("{\"")) {
                            log.debug("跳过非 JSON 行:{}", StrUtil.sub(trimmed, 0, 50));
                            return Flux.empty();
                        }
                        
                        // chunk 是 JSON 字符串,需要解析为 JSONObject
                        JSONObject node = JSONUtil.parseObj(chunk);
                        String event = node.getStr("event");

                        if ("message".equals(event) || "agent_message".equals(event)) {
                            // 提取内容片段
                            String answer = node.getStr("answer");
                            fullAnswer.append(answer);
                            tokenCount.addAndGet(node.getInt("count", 0));

                            // 重新构建干净的 JSON 响应,避免乱码
                            cn.hutool.json.JSONObject responseJson = new cn.hutool.json.JSONObject();
                            responseJson.set("event", "message");
                            responseJson.set("conversation_id", node.getStr("conversation_id", ""));
                            responseJson.set("message_id", node.getStr("message_id", ""));
                            responseJson.set("answer", answer); // 直接使用提取出的 answer,确保编码正确
                            responseJson.set("created_at", node.getLong("created_at", System.currentTimeMillis() / 1000));
                            
                            // 返回 SSE 事件给前端
                            return Flux.just(ServerSentEvent.<String>builder()
                                    .id(node.getStr("message_id"))
                                    .event("message")
                                    .data(JSON.toJSONString(responseJson)) // 使用 FastJSON2 序列化,确保 UTF-8 编码
                                    .build());
                        } else if ("message_end".equals(event)) {
                            // 流结束,如果是首次对话,需要保存 Dify 返回的 conversationId
                            String difyConversationId = node.getStr("conversation_id");
                            
                            // 如果是首次对话且获取到了 Dify 的 conversationId,更新数据库
                            if (isFirstChat && StringUtils.isNotBlank(difyConversationId)) {
                                updateConversationWithDifyId(tempConversationIdRef[0], difyConversationId, userId);
                            }
                            
                            // 保存完整记录
                            saveCompletedMessage(tempMessageId, difyConversationId, userId,
                                    request.getQuery(), fullAnswer.toString(),
                                    tokenCount.get(), System.currentTimeMillis() - startTime);
                            
                            // 异步更新会话标题(从 Dify 获取)
                            updateConversationTitleAsync(difyConversationId, userId);

                            return Flux.just(ServerSentEvent.<String>builder()
                                    .event("end")
                                    .data("{\"status\":\"completed\"}")
                                    .build());
                        }
                        // 其他事件类型,跳过(返回空 Flux)
                        return Flux.empty();
                    } catch (Exception e) {
                        log.error("处理 SSE 消息失败", e);
                        return Flux.empty();
                    }
                })
                .doOnError(e -> {
                    log.error("流式对话异常", e);
                    // 记录失败日志,可接入告警
                });
    }

    /**
     * 发送消息(阻塞模式)
     */
    @Override
    public ChatResponse chatBlocking(ChatRequest request, Long userId) {
        // 1. 判断是否为首次对话
        boolean isFirstChat = StringUtils.isBlank(request.getConversationId());
        
        // 2. 如果是首次对话,先创建临时会话记录
        String tempConversationId = null;
        if (isFirstChat) {
            tempConversationId = createTempConversation(userId, request.getDeviceSn(), 
                    request.getBatteryId(), request.getQuery());
        }
        
        // 3. 构建 Dify 请求(首次对话时 conversationId 为 null)
        String finalConversationId = isFirstChat ? null : request.getConversationId();
        DifyChatRequest difyRequest = buildDifyRequest(request, userId, finalConversationId);

        long startTime = System.currentTimeMillis();
        DifyChatResponse response = difyApiClient.sendMessageBlocking(difyRequest);
        long latency = System.currentTimeMillis() - startTime;
        
        // 4. 如果是首次对话且获取到了 Dify 的 conversationId,更新数据库
        String actualConversationId = finalConversationId;
        if (isFirstChat && StringUtils.isNotBlank(response.getConversationId())) {
            actualConversationId = response.getConversationId();
            updateConversationWithDifyId(tempConversationId, actualConversationId, userId);
        }

        // 5. 保存记录
        saveMessageRecord(response, actualConversationId, userId, request.getQuery(), latency);

        // 6. 异步分析热点问题
        analyticsService.analyzeQuestionAsync(request.getQuery(), request.getDeviceSn());

        return ChatResponse.builder()
                .conversationId(actualConversationId)
                .messageId(response.getMessageId())
                .answer(response.getAnswer())
                .latencyMs(latency)
                .build();
    }

    /**
     * 获取历史聊天记录(支持分页)
     */
    @Override
    public PageResult<ChatMessageVO> getChatHistory(Long userId, String conversationId,
                                                    String lastMessageId, Integer limit) {
        // 1. 先查本地数据库(更可靠,且包含用户反馈等扩展字段)
        List<AiChatMessage> messages = messageMapper.selectByConversation(
                conversationId, lastMessageId, limit != null ? limit : 100);

        // 2. 如果本地没有,尝试从 Dify 同步(首次加载或数据丢失时)
        if (messages.isEmpty() && lastMessageId == null) {
            syncHistoryFromDify(conversationId, userId);
            messages = messageMapper.selectByConversation(conversationId, null, 100);
        }

        // 3. 转换为 VO
        List<ChatMessageVO> voList = messages.stream().map(msg -> {
            ChatMessageVO vo = new ChatMessageVO();
            BeanUtils.copyProperties(msg, vo);
            vo.setIsUser(true); // 标记是否为用户消息(根据 parent_message_id 判断)
            return vo;
        }).collect(Collectors.toList());

        return new PageResult<>(voList, voList.size(), 1, limit);
    }

    /**
     * 获取用户的会话列表(优化版:解决 N+1 查询问题)
     */
    @Override
    public List<ConversationVO> getUserConversations(Long userId, Integer limit) {
        // 1. 查询会话列表
        List<AiConversation> conversations = conversationMapper.selectByUserId(userId, limit);
        if (conversations == null || conversations.isEmpty()) {
            return new ArrayList<>();
        }

        // 2. 提取所有 conversationId
        List<String> conversationIds = conversations.stream()
                .map(AiConversation::getConversationId)
                .collect(Collectors.toList());

        // 3. 批量查询这些会话的最后一条消息
        List<AiChatMessage> lastMessages = messageMapper.selectLastMessagesByConversationIds(conversationIds);
        
        // 4. 转为 Map 方便内存匹配:Key=conversationId, Value=Message
        Map<String, AiChatMessage> messageMap = lastMessages.stream()
                .collect(Collectors.toMap(
                        AiChatMessage::getConversationId, 
                        msg -> msg, 
                        (v1, v2) -> v1)); // 如果有重复,取第一个

        // 5. 内存组装 VO
        return conversations.stream().map(conv -> {
            ConversationVO vo = new ConversationVO();
            BeanUtils.copyProperties(conv, vo);
            
            // 从 Map 中直接获取,无需查库
            AiChatMessage lastMsg = messageMap.get(conv.getConversationId());
            if (lastMsg != null) {
                // 优先显示用户的提问(query),因为前端更关心用户最后问的是什么
                vo.setLastMessagePreview(StringUtils.abbreviate(lastMsg.getQuery(), 50));
            }
            return vo;
        }).collect(Collectors.toList());
    }

    /**
     * 用户反馈(点赞/点踩)
     */
    @Override
    @Transactional
    public void submitFeedback(String messageId, Integer feedback, String reason, Long userId) {
        messageMapper.updateFeedback(messageId, userId, feedback, reason);

        // 如果点踩,可接入告警或人工审核流程
        if (feedback == 2) {
            log.warn("用户点踩消息,messageId: {}, reason: {}", messageId, reason);
            // TODO: 发送通知给运营人员
        }
    }

    /**
     * 从 Dify 同步历史消息到本地数据库(用于数据丢失恢复)
     *
     * @param conversationId 会话 ID
     * @param userId         用户 ID
     */
    @Async
    public void syncHistoryFromDify(String conversationId, Long userId) {
        try {
            log.info("开始从 Dify 同步历史消息 - conversationId: {}, userId: {}", conversationId, userId);
            
            // 调用 Dify API 获取历史消息
            List<DifyMessage> difyMessages = difyApiClient.getHistoryMessages(
                    conversationId, userId.toString(), null, 100);
            
            if (difyMessages == null || difyMessages.isEmpty()) {
                log.warn("Dify 没有返回历史消息 - conversationId: {}", conversationId);
                return;
            }
            
            log.info("从 Dify 获取到 {} 条历史消息", difyMessages.size());
            
            // 逐条保存到数据库
            int savedCount = 0;
            for (DifyMessage difyMsg : difyMessages) {
                try {
                    // 检查消息是否已存在(避免重复保存)
                    AiChatMessage existingMsg = messageMapper.selectByMessageId(difyMsg.getId());
                    if (existingMsg != null) {
                        log.debug("消息已存在,跳过 - messageId: {}", difyMsg.getId());
                        continue;
                    }
                    
                    // 创建新的消息记录
                    AiChatMessage message = new AiChatMessage();
                    message.setMessageId(difyMsg.getId());
                    message.setConversationId(conversationId);
                    message.setUserId(userId);
                    message.setParentMessageId(difyMsg.getParentMessageId());
                    message.setQuery(difyMsg.getQuery());
                    message.setAnswer(difyMsg.getAnswer());
                    message.setStatus("error".equals(difyMsg.getStatus()) ? 0 : 1);
                    message.setMessageType(1); // 默认为文本类型
                    message.setFeedback(0);
                    
                    // 如果有反馈信息
                    if (difyMsg.getFeedback() != null) {
                        String rating = difyMsg.getFeedback().getRating();
                        if ("like".equals(rating)) {
                            message.setFeedback(1); // 点赞
                        } else if ("dislike".equals(rating)) {
                            message.setFeedback(2); // 点踩
                        }
                        message.setFeedbackReason(difyMsg.getFeedback().getContent());
                    }
                    
                    // 设置创建时间
                    if (difyMsg.getCreatedAt() != null) {
                        message.setCreatedAt(LocalDateTime.ofEpochSecond(difyMsg.getCreatedAt(), 0, java.time.ZoneOffset.UTC));
                    } else {
                        message.setCreatedAt(LocalDateTime.now());
                    }
                    
                    message.setCreatedBy(String.valueOf(userId));
                    
                    // 插入数据库
                    messageMapper.insert(message);
                    savedCount++;
                    
                } catch (Exception e) {
                    log.error("保存单条消息失败 - messageId: {}, error: {}", difyMsg.getId(), e.getMessage());
                }
            }
            
            log.info("从 Dify 同步历史消息完成 - conversationId: {}, 获取总数:{}, 成功保存:{}",
                    conversationId, difyMessages.size(), savedCount);
            
        } catch (Exception e) {
            log.error("从 Dify 同步历史消息异常 - conversationId: {}, error: {}", conversationId, e.getMessage(), e);
        }
    }

    // ============ 私有方法 ============

    /**
     * 创建临时会话记录(首次对话时使用,等待 Dify 返回真正的 conversationId)
     *
     * @param userId   用户 ID
     * @param deviceSn 设备序列号
     * @param batteryId 电池 ID
     * @param query    用户问题
     * @return 临时会话 ID(用于后续更新)
     */
    private String createTempConversation(Long userId, String deviceSn, Long batteryId, String query) {

        // 创建临时会话记录(conversationId 为空,等待 Dify 返回)
        String tempId = "temp_" + IdUtil.fastSimpleUUID();
        AiConversation newConv = new AiConversation();
        newConv.setConversationId(tempId); // 临时 ID,稍后会被替换
        newConv.setUserId(userId);
        newConv.setDeviceSn(deviceSn);
        newConv.setBatteryId(batteryId);
        newConv.setStatus(1);
        String title = StrUtil.isNotEmpty(deviceSn)
                ? "关于设备" + deviceSn + "的咨询"
                : StrUtil.sub(query, 0, 20);
        newConv.setTitle(title);
        newConv.setSource("web");
        newConv.setMessageCount(0);
        newConv.setCreatedBy(String.valueOf(userId));
        conversationMapper.insert(newConv);
        
        return tempId;
    }
    
    /**
     * 使用 Dify 返回的 conversationId 更新临时会话记录
     *
     * @param tempId          临时会话 ID
     * @param difyConversationId Dify 返回的会话 ID
     * @param userId          用户 ID
     */
    private void updateConversationWithDifyId(String tempId, String difyConversationId, Long userId) {
        try {
            AiConversation localConv = conversationMapper.selectByConversationId(tempId);
            if (localConv != null) {
                // 更新为 Dify 生成的 conversationId
                localConv.setConversationId(difyConversationId);
                localConv.setUpdatedBy(String.valueOf(userId));
                conversationMapper.update(localConv);
                
                // 更新 Redis 缓存
                String cacheKey = SmartAnalysisRedisConstants.buildAiConversationKey(userId, localConv.getDeviceSn());
                redisUtil.set(cacheKey, difyConversationId, SmartAnalysisRedisConstants.TTL_24H);
                
                log.info("更新会话 ID 成功:tempId={}, difyConversationId={}", tempId, difyConversationId);
            }
        } catch (Exception e) {
            log.error("更新会话 ID 失败:tempId={}, difyConversationId={}", tempId, difyConversationId, e);
        }
    }


    private DifyChatRequest buildDifyRequest(ChatRequest request, Long userId, String conversationId) {
        DifyChatRequest req = new DifyChatRequest();
        req.setQuery(request.getQuery());
        req.setUser(userId.toString()); // Dify 的 user 字段是字符串
        req.setConversationId(conversationId);
        req.setInputs(request.getInputs()); // 传递设备参数等上下文

        // 如果有文件(如上传的电池故障截图)
        if (ObjectUtil.isNotEmpty(request.getFileIds())) {
            req.setFiles(request.getFileIds().stream().map(fileId -> {
                DifyFileInput file = new DifyFileInput();
                file.setType("image"); // 或根据实际类型
                file.setTransferMethod("local_file");
                file.setUploadFileId(fileId);
                return file;
            }).collect(Collectors.toList()));
        }

        return req;
    }

    /**
     * 移除 <think></think> 标签及其内容,只保留最终答案
     *
     * @param answer 原始回答
     * @return 清理后的回答
     */
    private String removeThinkTag(String answer) {
        if (StrUtil.isBlank(answer)) {
            return answer;
        }
        
        // 使用正则表达式移除 <think></think> 标签及其内容
        // 匹配 <think>开头,</think> 结尾的内容(包括换行符)
        String regex = "<think>[\\s\\S]*?</think>";
        String cleaned = answer.replaceAll(regex, "").trim();
        
        // 如果清理后为空,返回原文(防止误删)
        return StrUtil.isNotBlank(cleaned) ? cleaned : answer;
    }

    /**
     * 保存完整的消息记录到数据库
     *
     * @param messageId      消息 ID
     * @param conversationId 会话 ID
     * @param userId         用户 ID
     * @param query          用户问题
     * @param answer         AI 回答
     * @param tokens         消耗 token 数
     * @param latency        响应延迟(毫秒)
     */
    @Async
    public void saveCompletedMessage(String messageId, String conversationId, Long userId,
                                     String query, String answer, int tokens, long latency) {
        try {
            AiChatMessage message = new AiChatMessage();
            message.setMessageId(messageId);
            message.setConversationId(conversationId);
            message.setUserId(userId);
            message.setQuery(query);
            // 移除 <think></think> 标签及其内容,只保留最终答案
            String cleanAnswer = removeThinkTag(answer);
            message.setAnswer(cleanAnswer);
            // 将总 token 数拆分为 messageTokens 和 answerTokens(各占一半,或根据实际需要调整)
            int promptTokens = tokens / 2;
            int completionTokens = tokens - promptTokens;
            message.setMessageTokens(promptTokens);
            message.setAnswerTokens(completionTokens);
            message.setTotalTokens(tokens);
            message.setLatencyMs((int) latency);
            message.setCompletedAt(LocalDateTime.now());
            message.setStatus(1); // 状态:1-正常
            message.setMessageType(1); // 消息类型:1-文本
            message.setFeedback(0); // 反馈:0-无
            messageMapper.insert(message);

            // 更新会话统计
            conversationMapper.incrementMessageCount(conversationId);
            log.info("保存聊天记录成功:messageId={}, conversationId={}, tokens={}, latency={}ms",
                    messageId, conversationId, tokens, latency);
        } catch (Exception e) {
            log.error("保存聊天记录失败:messageId={}, conversationId={}", messageId, conversationId, e);
        }
    }

    /**
     * 保存阻塞模式的消息记录
     *
     * @param response       Dify 响应
     * @param conversationId 会话 ID
     * @param userId         用户 ID
     * @param query          用户问题
     * @param latency        响应延迟
     */
    @Async
    public void saveMessageRecord(DifyChatResponse response, String conversationId, Long userId,
                                  String query, long latency) {
        try {
            AiChatMessage message = new AiChatMessage();
            message.setMessageId(response.getMessageId());
            message.setConversationId(conversationId);
            message.setUserId(userId);
            message.setQuery(query);
            message.setAnswer(response.getAnswer());

            int promptTokens = 0;
            int answerTokens = 0;
            int totalTokens = 0;
            if (ObjectUtil.isNotEmpty(response.getMetadata()) && ObjectUtil.isNotEmpty(response.getMetadata().getUsage())) {
                promptTokens = response.getMetadata().getUsage().getPromptTokens();
                answerTokens = response.getMetadata().getUsage().getCompletionTokens();
                totalTokens = response.getMetadata().getUsage().getTotalTokens();
            }
            message.setMessageTokens(promptTokens);
            message.setAnswerTokens(answerTokens);
            message.setTotalTokens(totalTokens);
            message.setLatencyMs((int) latency);
            message.setCompletedAt(LocalDateTime.now());
            message.setStatus(1); // 状态:1-正常
            message.setMessageType(1); // 消息类型:1-文本
            message.setFeedback(0); // 反馈:0-无
            messageMapper.insert(message);

            // 更新会话统计
            conversationMapper.incrementMessageCount(conversationId);
            
            // 异步更新会话标题(从 Dify 获取)
            updateConversationTitleAsync(conversationId, userId);
            
            log.info("保存阻塞消息记录成功:messageId={}, conversationId={}, latency={}ms",
                    response.getMessageId(), conversationId, latency);
        } catch (Exception e) {
            log.error("保存阻塞消息记录失败:conversationId={}", conversationId, e);
        }
    }
    
    /**
     * 异步更新会话标题(从 Dify 获取自动生成的标题)
     *
     * @param conversationId 会话 ID
     * @param userId         用户 ID
     */
    @Async
    public void updateConversationTitleAsync(String conversationId, Long userId) {
        try {
            // 等待一小段时间,确保 Dify 已经生成标题
            Thread.sleep(2000);
            
            // 调用 Dify API 获取会话列表
            List<DifyConversation> conversations = difyApiClient.getConversations(
                    userId.toString(), 1, null);
            
            if (conversations != null && !conversations.isEmpty()) {
                DifyConversation latestConv = conversations.get(0);
                
                // 优先使用 name,如果没有则使用 summary
                String difyTitle = StrUtil.isNotBlank(latestConv.getName()) 
                        ? latestConv.getName() 
                        : latestConv.getSummary();
                
                if (StrUtil.isNotBlank(difyTitle)) {
                    // 更新数据库中的会话标题
                    AiConversation localConv = conversationMapper.selectByConversationId(conversationId);
                    if (localConv != null) {
                        // 如果当前标题是系统生成的默认标题,则替换为 Dify 生成的标题
                        if (localConv.getTitle().contains("关于设备") && localConv.getTitle().contains("的咨询")) {
                            localConv.setTitle(difyTitle);
                            localConv.setUpdatedBy(String.valueOf(userId));
                            conversationMapper.update(localConv);
                            log.info("更新会话标题成功:conversationId={}, 原标题:{}, 新标题:{}",
                                    conversationId, localConv.getTitle(), difyTitle);
                        }
                    }
                }
            }
        } catch (Exception e) {
            log.warn("异步更新会话标题失败:conversationId={}, 错误:{}", conversationId, e.getMessage());
        }
    }
}

package com.roypowtech.smartanalysis.service;

import com.roypowtech.smartanalysis.common.PageResult;
import com.roypowtech.smartanalysis.dto.aichat.ChatRequest;
import com.roypowtech.smartanalysis.dto.aichat.ChatResponse;
import com.roypowtech.smartanalysis.vo.ChatMessageVO;
import com.roypowtech.smartanalysis.vo.ConversationVO;
import org.springframework.http.codec.ServerSentEvent;
import reactor.core.publisher.Flux;

import java.util.List;

/**
 * AI 聊天业务服务接口
 */
public interface AiChatService {

    /**
     * 发送消息(流式)
     * @param request 请求参数
     * @param userId 用户 ID
     * @return SSE 流
     */
    Flux<ServerSentEvent<String>> chatStream(ChatRequest request, Long userId);

    /**
     * 发送消息(阻塞模式)
     * @param request 请求参数
     * @param userId 用户 ID
     * @return 响应
     */
    ChatResponse chatBlocking(ChatRequest request, Long userId);

    /**
     * 获取历史聊天记录(支持分页)
     * @param userId 用户 ID
     * @param conversationId 会话 ID
     * @param lastMessageId 最后一条消息 ID
     * @param limit 每页数量
     * @return 分页结果
     */
    PageResult<ChatMessageVO> getChatHistory(Long userId, String conversationId,
                                              String lastMessageId, Integer limit);

    /**
     * 获取用户的会话列表
     * @param userId 用户 ID
     * @param limit 每页数量
     * @return 会话列表
     */
    List<ConversationVO> getUserConversations(Long userId, Integer limit);

    /**
     * 用户反馈(点赞/点踩)
     * @param messageId 消息 ID
     * @param feedback 反馈类型 (1-点赞,2-点踩)
     * @param reason 反馈原因
     * @param userId 用户 ID
     */
    void submitFeedback(String messageId, Integer feedback, String reason, Long userId);
}

统计分析:

java 复制代码
package com.roypowtech.smartanalysis.service;

import cn.hutool.core.util.StrUtil;
import com.roypowtech.smartanalysis.entity.AiChatAnalytics;
import com.roypowtech.smartanalysis.mapper.AiChatAnalyticsMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * AI 聊天分析服务
 * 用于热点问题统计、用户行为分析、服务质量监控
 *
 * @author MXF
 * @date 2026-03-19
 */
@Service
@Slf4j
@RequiredArgsConstructor
public class ChatAnalyticsService {

    private final AiChatAnalyticsMapper analyticsMapper;

    // 问题分类关键词映射
    private static final Map<String, String> CATEGORY_KEYWORDS = new HashMap<>();
    
    static {
        CATEGORY_KEYWORDS.put("SOH", "soh|健康度|衰减|寿命");
        CATEGORY_KEYWORDS.put("SOC", "soc|电量|剩余容量");
        CATEGORY_KEYWORDS.put("ALERT", "告警|预警|报警|异常");
        CATEGORY_KEYWORDS.put("TEMPERATURE", "温度|温升|发热|散热");
        CATEGORY_KEYWORDS.put("DISCHARGE", "放电|用电|电流");
        CATEGORY_KEYWORDS.put("CHARGE", "充电|充满|充电器");
        CATEGORY_KEYWORDS.put("RELAY", "继电器|粘连|开关");
        CATEGORY_KEYWORDS.put("BMS", "bms|电池管理");
    }

    /**
     * 异步分析问题并更新统计
     *
     * @param question 用户问题
     * @param deviceSn 设备序列号(可选)
     */
    @Async
    public void analyzeQuestionAsync(String question, String deviceSn) {
        try {
            if (StrUtil.isBlank(question)) {
                return;
            }

            LocalDate today = LocalDate.now();
            
            // 1. 提取问题模式(标准化问法)
            String questionPattern = extractQuestionPattern(question);
            
            // 2. 提取核心关键词
            String keyword = extractKeyword(question);
            
            // 3. 判断问题分类
            String category = categorizeQuestion(question);
            
            // 4. 查询或创建统计记录
            AiChatAnalytics stats = analyticsMapper.selectByDateAndPattern(today, questionPattern);
            
            if (stats == null) {
                // 创建新记录
                stats = new AiChatAnalytics();
                stats.setStatDate(today);
                stats.setQuestionPattern(questionPattern);
                stats.setQuestionKeyword(keyword);
                stats.setQuestionCategory(category);
                stats.setCount(1);
                stats.setUniqueUsers(1);
                stats.setDeviceType(extractDeviceType(deviceSn));
                stats.setCreatedBy("system");
                
                // 计算趋势(与昨天对比)
                calculateTrend(stats, today, questionPattern);
                
                analyticsMapper.insert(stats);
            } else {
                // 更新已有记录
                analyticsMapper.incrementCount(today, questionPattern);
            }
            
            log.debug("AI 问答分析 - 问题:{}, 分类:{}, 关键词:{}", question, category, keyword);
            
        } catch (Exception e) {
            log.error("AI 问答分析失败 - question: {}", question, e);
        }
    }

    /**
     * 提取问题模式(标准化问法)
     * 例如:"SOH 衰减太快了怎么办" -> "SOH 衰减原因"
     */
    private String extractQuestionPattern(String question) {
        // 简化处理,去除疑问词和语气词
        String pattern = question.replaceAll("[吗?呢?啊?呀?怎么?为什么?如何?]", "")
                .replaceAll("\\s+", " ")
                .trim();
        
        // 如果问题太长,截取前 50 个字符
        if (pattern.length() > 50) {
            pattern = pattern.substring(0, 50);
        }
        
        return pattern;
    }

    /**
     * 提取核心关键词
     */
    private String extractKeyword(String question) {
        // 简单实现:提取第一个名词性词汇
        // 实际项目中可以使用 NLP 库如 HanLP 进行更精确的提取
        
        for (Map.Entry<String, String> entry : CATEGORY_KEYWORDS.entrySet()) {
            Pattern p = Pattern.compile(entry.getValue(), Pattern.CASE_INSENSITIVE);
            Matcher m = p.matcher(question);
            if (m.find()) {
                return m.group().toUpperCase();
            }
        }
        
        // 默认返回 SOH(最常见的问题)
        return "GENERAL";
    }

    /**
     * 判断问题分类
     */
    private String categorizeQuestion(String question) {
        String lowerQuestion = question.toLowerCase();
        
        for (Map.Entry<String, String> entry : CATEGORY_KEYWORDS.entrySet()) {
            if (lowerQuestion.matches(".*(" + entry.getValue() + ").*")) {
                return entry.getKey();
            }
        }
        
        return "OTHER";
    }

    /**
     * 从设备 SN 中提取设备类型
     */
    private String extractDeviceType(String deviceSn) {
        if (StrUtil.isBlank(deviceSn)) {
            return null;
        }
        
        // 简单示例:SN20260310001 -> LFP100Ah
        // 实际项目中根据 SN 编码规则解析
        if (deviceSn.startsWith("SN")) {
            return "LFP100Ah"; // 示例
        }
        
        return null;
    }

    /**
     * 计算问题趋势(与昨天对比)
     */
    private void calculateTrend(AiChatAnalytics stats, LocalDate today, String questionPattern) {
        try {
            LocalDate yesterday = today.minusDays(1);
            AiChatAnalytics yesterdayStats = analyticsMapper.selectByDateAndPattern(yesterday, questionPattern);
            
            if (yesterdayStats == null) {
                stats.setTrend("FLAT");
            } else {
                int todayCount = stats.getCount();
                int yesterdayCount = yesterdayStats.getCount();
                
                if (todayCount > yesterdayCount) {
                    stats.setTrend("UP");
                } else if (todayCount < yesterdayCount) {
                    stats.setTrend("DOWN");
                } else {
                    stats.setTrend("FLAT");
                }
            }
        } catch (Exception e) {
            log.warn("计算趋势失败", e);
            stats.setTrend("FLAT");
        }
    }
}

工具类:

java 复制代码
package com.roypowtech.smartanalysis.utils;

import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.Header;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.roypowtech.smartanalysis.dto.aichat.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;

import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Dify API 客户端封装
 * 使用 Hutool HttpUtil 实现 HTTP 调用
 * 支持阻塞模式和流式 (SSE) 模式
 */
@Service
@Slf4j
public class DifyApiClient {

    @Value("${dify.base-url:http://127.0.0.1/v1}")
    private String baseUrl;

    @Value("${dify.api-key:your-api-key}")
    private String apiKey;

    @Value("${dify.app-type:chat}")
    private String appType; // chat 或 workflow

    @Value("${dify.timeout:60s}")
    private String timeoutStr;

    @Value("${dify.connection.connection-timeout:5000}")
    private int connectionTimeout;

    @Value("${dify.connection.read-timeout:60000}")
    private int timeout;

    /**
     * 阻塞模式发送消息(适用于简单问答)
     */
    public DifyChatResponse sendMessageBlocking(DifyChatRequest request) {
        long startTime = System.currentTimeMillis();
        String url = null;

        try {
            // 根据应用类型选择正确的接口路由
            String endpoint = "workflow".equals(appType) ? "/workflows/run" : "/chat-messages";
            url = baseUrl + endpoint;

            //设置响应模式为 blocking-阻塞
            request.setResponseMode("blocking");

            // 确保 inputs 不为 null
            if (ObjectUtil.isEmpty(request.getInputs())) {
                request.setInputs(new HashMap<>());
            }

            // 序列化请求体(使用 Hutool JSONUtil,@Alias 注解会自动生效)
            String requestBody = JSONUtil.toJsonStr(request);

            // ========== 关键日志:请求信息 ==========
            log.info("[Dify 请求] 类型:{}, 用户:{}, 对话 ID: {}",
                    appType, request.getUser(), request.getConversationId());
            log.debug("[Dify 请求] URL: {}", url);
            log.debug("[Dify 请求] Body: {}", requestBody);

            // 发送 POST 请求(使用 Java 原生 HttpURLConnection)
            HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
            conn.setRequestMethod("POST");
            conn.setDoOutput(true);
            conn.setConnectTimeout(connectionTimeout);
            conn.setReadTimeout(timeout);
            conn.setRequestProperty("Authorization", "Bearer " + apiKey);
            conn.setRequestProperty("Content-Type", "application/json");
            
            try (OutputStream os = conn.getOutputStream()) {
                byte[] input = requestBody.getBytes(StandardCharsets.UTF_8);
                os.write(input, 0, input.length);
            }
            
            int statusCode = conn.getResponseCode();
            long costTime = System.currentTimeMillis() - startTime;
            
            if (statusCode == 200) {
                try (BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
                    StringBuilder response = new StringBuilder();
                    String line;
                    while ((line = reader.readLine()) != null) {
                        response.append(line);
                    }
                    String responseBody = response.toString();
                    
                    log.info("[Dify 响应] 状态码:{}, 耗时:{}ms", statusCode, costTime);
                    log.debug("[Dify 响应] 原始响应:{}", StrUtil.sub(responseBody, 0, 500));
                    
                    DifyChatResponse result = JSONUtil.toBean(responseBody, DifyChatResponse.class);
                    log.info("[Dify 成功] 消息 ID: {}, 会话 ID: {}, 回答长度:{}",
                            result.getMessageId(),
                            result.getConversationId(),
                            StrUtil.length(result.getAnswer()));
                    return result;
                }
            } else {
                String errorBody = new BufferedReader(new InputStreamReader(conn.getErrorStream(), StandardCharsets.UTF_8))
                        .lines().reduce("", (a, b) -> a + b);
                log.error("[Dify 错误] 状态码:{}, 响应:{}", statusCode, errorBody);
                throw new DifyApiException("Dify API 错误:" + statusCode + " - " + errorBody);
            }
        } catch (Exception e) {
            log.error("[Dify 异常] 阻塞调用请求失败 - URL: {}, 用户:{}, 错误:{}",
                    url, request.getUser(), e.getMessage(), e);
            throw new DifyApiException("AI 服务暂时不可用,请稍后重试");
        }
    }

    /**
     * 流式模式发送消息(SSE)
     */
    public Flux<String> sendMessageStreaming(DifyChatRequest request) {
        request.setResponseMode("streaming");
        String url = baseUrl + "/chat-messages";
        //设置响应模式为 streaming-流式
        request.setResponseMode("streaming");

        // 确保 inputs 不为 null
        if (ObjectUtil.isEmpty(request.getInputs())) {
            request.setInputs(new HashMap<>());
        }

        // ========== 关键日志:流式请求 ==========
        log.info("[Dify 流式请求] 开始 - 用户:{}, 对话 ID: {}",
                request.getUser(), request.getConversationId());
        log.debug("[Dify 流式请求] URL: {}", url);
        log.debug("[Dify 流式请求] Query: {}", StrUtil.sub(request.getQuery(), 0, 100));

        return Flux.<String>create(emitter -> {
                    long startTime = System.currentTimeMillis();
                    int eventCount = 0;

                    try {
                        // 使用 HttpURLConnection 实现真正的流式 SSE 读取
                        HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
                        conn.setRequestMethod("POST");
                        conn.setDoOutput(true);
                        conn.setConnectTimeout(connectionTimeout);
                        conn.setReadTimeout(timeout);
                        conn.setRequestProperty("Authorization", "Bearer " + apiKey);
                        conn.setRequestProperty("Content-Type", "application/json");
                        conn.setRequestProperty("Accept", "text/event-stream");
                        
                        // 发送请求体
                        String requestBody = JSONUtil.toJsonStr(request);
                        try (OutputStream os = conn.getOutputStream()) {
                            byte[] input = requestBody.getBytes(StandardCharsets.UTF_8);
                            os.write(input, 0, input.length);
                        }
                        
                        int statusCode = conn.getResponseCode();
                        
                        if (statusCode == 200) {
                            // 关键优化:使用 BufferedReader 逐行读取响应流,实现真正的流式处理
                            try (BufferedReader reader = new BufferedReader(
                                    new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
                                String line;
                                
                                // 逐行读取并立即发出,实现真正的实时流式
                                while ((line = reader.readLine()) != null) {
                                    if (!emitter.isCancelled()) {
                                        emitter.next(line);
                                        if (line.startsWith("data: ")) {
                                            eventCount++;
                                        }
                                    } else {
                                        break; // 客户端取消则停止读取
                                    }
                                }
                            }

                            long costTime = System.currentTimeMillis() - startTime;
                            log.info("[Dify 流式完成] 用户:{}, 事件数:{}, 总耗时:{}ms",
                                    request.getUser(), eventCount, costTime);
                            emitter.complete();
                        } else {
                            log.error("[Dify 流式错误] 状态码:{}", statusCode);
                            emitter.error(new DifyApiException("SSE 请求失败:" + statusCode));
                        }
                    } catch (Exception e) {
                        log.error("[Dify 流式异常] 用户:{}, 错误:{}", request.getUser(), e.getMessage());
                        emitter.error(e);
                    }
                }).subscribeOn(Schedulers.boundedElastic())
                .map(line -> {
                    if (line.startsWith("data: ")) {
                        return line.substring(6);
                    }
                    return line;
                })
                .filter(line -> !line.isEmpty());
    }

    /**
     * 获取历史消息
     */
    public List<DifyMessage> getHistoryMessages(String conversationId, String userId,
                                                String firstId, Integer limit) {
        long startTime = System.currentTimeMillis();
        String url = baseUrl + "/messages";

        try {
            // 构建请求参数
            Map<String, Object> params = new HashMap<>();
            params.put("conversation_id", conversationId);
            params.put("user", userId);
            if (StrUtil.isNotBlank(firstId)) {
                params.put("first_id", firstId);
            }
            params.put("limit", limit != null ? limit : 20);

            Map<String, String> headers = new HashMap<>();
            headers.put("Authorization", "Bearer " + apiKey);

            // ========== 关键日志:历史消息查询 ==========
            log.info("[Dify历史消息] 查询 - 会话ID: {}, 用户: {}, 数量: {}",
                    conversationId, userId, limit);

            HttpResponse response = HttpRequest.get(url)
                    .headerMap(headers, false)
                    .form(params)
                    .timeout(timeout)
                    .execute();

            long costTime = System.currentTimeMillis() - startTime;

            if (response.isOk()) {
                String responseBody = response.body();
                log.debug("[Dify历史消息] 响应: {}", StrUtil.sub(responseBody, 0, 300));

                DifyMessageListResponse listResponse = JSONUtil.toBean(responseBody, DifyMessageListResponse.class);
                int msgCount = listResponse.getData() != null ? listResponse.getData().size() : 0;
                log.info("[Dify历史消息] 成功 - 获取 {} 条消息, 耗时: {}ms", msgCount, costTime);

                return listResponse.getData();
            } else {
                log.warn("[Dify历史消息] 失败 - 状态码: {}, 会话: {}", response.getStatus(), conversationId);
                return Collections.emptyList();
            }
        } catch (Exception e) {
            log.error("[Dify历史消息] 异常 - 会话ID: {}, 错误: {}", conversationId, e.getMessage());
            return Collections.emptyList();
        }
    }

    /**
     * 获取会话列表
     */
    public List<DifyConversation> getConversations(String userId, Integer limit, String lastId) {
        long startTime = System.currentTimeMillis();
        String url = baseUrl + "/conversations";

        try {
            Map<String, Object> params = new HashMap<>();
            params.put("user", userId);
            params.put("limit", limit != null ? limit : 20);
            if (StrUtil.isNotBlank(lastId)) {
                params.put("last_id", lastId);
            }

            Map<String, String> headers = new HashMap<>();
            headers.put("Authorization", "Bearer " + apiKey);

            // ========== 关键日志:会话列表查询 ==========
            log.info("[Dify会话列表] 查询 - 用户: {}, 数量: {}", userId, limit);

            HttpResponse response = HttpRequest.get(url)
                    .headerMap(headers, false)
                    .form(params)
                    .timeout(timeout)
                    .execute();

            long costTime = System.currentTimeMillis() - startTime;

            if (response.isOk()) {
                String responseBody = response.body();
                log.debug("[Dify会话列表] 响应: {}", StrUtil.sub(responseBody, 0, 300));

                DifyConversationListResponse listResponse = JSONUtil.toBean(responseBody, DifyConversationListResponse.class);
                int convCount = listResponse.getData() != null ? listResponse.getData().size() : 0;
                log.info("[Dify会话列表] 成功 - 获取 {} 个会话, 耗时: {}ms", convCount, costTime);

                return listResponse.getData();
            } else {
                log.warn("[Dify会话列表] 失败 - 状态码: {}, 用户: {}", response.getStatus(), userId);
                return Collections.emptyList();
            }
        } catch (Exception e) {
            log.error("[Dify会话列表] 异常 - 用户: {}, 错误: {}", userId, e.getMessage());
            return Collections.emptyList();
        }
    }

    /**
     * 上传文件(用于图片对话)
     */
    public DifyFileUploadResponse uploadFile(MultipartFile file, String userId) {
        String url = baseUrl + "/files/upload";
        long startTime = System.currentTimeMillis();

        File tempFile = null;
        try {
            // MultipartFile转临时File
            tempFile = File.createTempFile("dify_", "_" + file.getOriginalFilename());
            file.transferTo(tempFile);

            // ========== 关键日志:文件上传 ==========
            log.info("[Dify文件上传] 开始 - 用户: {}, 文件名: {}, 大小: {}bytes",
                    userId, file.getOriginalFilename(), file.getSize());

            String response = HttpRequest.post(url)
                    .header(Header.AUTHORIZATION, "Bearer " + apiKey)
                    .form("user", userId)
                    .form("file", tempFile)
                    .timeout(timeout)
                    .execute()
                    .body();

            long costTime = System.currentTimeMillis() - startTime;
            log.debug("[Dify文件上传] 响应: {}", response);

            JSONObject json = JSONUtil.parseObj(response);
            DifyFileUploadResponse result = parseFileResponse(json);

            log.info("[Dify文件上传] 成功 - 文件ID: {}, 耗时: {}ms", result.getId(), costTime);
            return result;

        } catch (Exception e) {
            log.error("[Dify文件上传] 失败 - 用户: {}, 文件名: {}, 错误: {}",
                    userId, file.getOriginalFilename(), e.getMessage());
            throw new DifyApiException("文件上传失败: " + e.getMessage(), e);
        } finally {
            if (tempFile != null) tempFile.delete();
        }
    }

    /**
     * 自定义异常
     */
    public static class DifyApiException extends RuntimeException {
        public DifyApiException(String message) { super(message); }
        public DifyApiException(String message, Throwable cause) { super(message, cause); }
    }

    private DifyFileUploadResponse parseFileResponse(JSONObject json) {
        DifyFileUploadResponse res = new DifyFileUploadResponse();
        res.setId(json.getStr("id"));
        res.setName(json.getStr("name"));
        res.setSize(json.getLong("size"));
        return res;
    }

}

xml:

java 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.roypowtech.smartanalysis.mapper.AiChatAnalyticsMapper">

    <!-- 通用结果映射 -->
    <resultMap id="BaseResultMap" type="com.roypowtech.smartanalysis.entity.AiChatAnalytics">
        <id column="id" property="id"/>
        <result column="stat_date" property="statDate"/>
        <result column="question_pattern" property="questionPattern"/>
        <result column="question_keyword" property="questionKeyword"/>
        <result column="question_category" property="questionCategory"/>
        <result column="count" property="count"/>
        <result column="unique_users" property="uniqueUsers"/>
        <result column="device_type" property="deviceType"/>
        <result column="avg_response_time" property="avgResponseTime"/>
        <result column="satisfaction_rate" property="satisfactionRate"/>
        <result column="trend" property="trend"/>
        <result column="created_by" property="createdBy"/>
        <result column="updated_by" property="updatedBy"/>
        <result column="created_at" property="createdAt"/>
        <result column="updated_at" property="updatedAt"/>
    </resultMap>

    <!-- 通用查询列 -->
    <sql id="Base_Column_List">
        id, stat_date, question_pattern, question_keyword, question_category, count, 
        unique_users, device_type, avg_response_time, satisfaction_rate, trend, 
        created_by, updated_by, created_at, updated_at
    </sql>

    <!-- 根据日期和问题模式查询 -->
    <select id="selectByDateAndPattern" resultMap="BaseResultMap">
        SELECT
        <include refid="Base_Column_List"/>
        FROM im_ai_chat_analytics
        WHERE stat_date = #{statDate}
        AND question_pattern = #{questionPattern}
    </select>

    <!-- 增加问题计数 -->
    <update id="incrementCount">
        UPDATE im_ai_chat_analytics
        SET count = count + 1,
            updated_at = CURRENT_TIMESTAMP
        WHERE stat_date = #{statDate}
        AND question_pattern = #{questionPattern}
    </update>

    <!-- 查询热门问题 TOP N -->
    <select id="selectTopQuestions" resultMap="BaseResultMap">
        SELECT
        <include refid="Base_Column_List"/>
        FROM im_ai_chat_analytics
        WHERE stat_date BETWEEN #{startDate} AND #{endDate}
        ORDER BY count DESC
        LIMIT #{limit}
    </select>

    <!-- 按分类统计 -->
    <select id="selectCategoryStats" resultMap="BaseResultMap">
        SELECT
        question_category,
        SUM(count) as count,
        COUNT(DISTINCT question_pattern) as unique_users
        FROM im_ai_chat_analytics
        WHERE stat_date = #{statDate}
        GROUP BY question_category
        ORDER BY count DESC
    </select>

    <!-- ==================== 基本 CRUD 操作 ==================== -->

    <!-- 插入分析记录 -->
    <insert id="insert" parameterType="com.roypowtech.smartanalysis.entity.AiChatAnalytics" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO im_ai_chat_analytics (
            stat_date, question_pattern, question_keyword, question_category, count,
            unique_users, device_type, avg_response_time, satisfaction_rate, trend,
            created_by, updated_by, created_at, updated_at
        ) VALUES (
            #{statDate}, #{questionPattern}, #{questionKeyword}, #{questionCategory}, #{count},
            #{uniqueUsers}, #{deviceType}, #{avgResponseTime}, #{satisfactionRate}, #{trend},
            #{createdBy}, #{updatedBy}, NOW(), NOW()
        )
    </insert>

    <!-- 更新分析记录 -->
    <update id="update" parameterType="com.roypowtech.smartanalysis.entity.AiChatAnalytics">
        UPDATE im_ai_chat_analytics
        <set>
            <if test="questionPattern != null">question_pattern = #{questionPattern},</if>
            <if test="questionKeyword != null">question_keyword = #{questionKeyword},</if>
            <if test="questionCategory != null">question_category = #{questionCategory},</if>
            <if test="count != null">count = #{count},</if>
            <if test="uniqueUsers != null">unique_users = #{uniqueUsers},</if>
            <if test="deviceType != null">device_type = #{deviceType},</if>
            <if test="avgResponseTime != null">avg_response_time = #{avgResponseTime},</if>
            <if test="satisfactionRate != null">satisfaction_rate = #{satisfactionRate},</if>
            <if test="trend != null">trend = #{trend},</if>
            <if test="updatedBy != null">updated_by = #{updatedBy},</if>
            updated_at = NOW()
        </set>
        WHERE id = #{id}
    </update>

    <!-- 删除分析记录(逻辑删除) -->
    <delete id="deleteById" parameterType="java.lang.Long">
        DELETE FROM im_ai_chat_analytics WHERE id = #{id}
    </delete>

    <!-- 批量删除分析记录 -->
    <delete id="batchDelete" parameterType="java.util.List">
        DELETE FROM im_ai_chat_analytics WHERE id IN
        <foreach collection="ids" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </delete>

    <!-- 根据 ID 查询分析记录 -->
    <select id="selectById" parameterType="java.lang.Long" resultMap="BaseResultMap">
        SELECT
        <include refid="Base_Column_List"/>
        FROM im_ai_chat_analytics
        WHERE id = #{id}
    </select>

    <!-- 查询所有分析记录 -->
    <select id="selectAll" resultMap="BaseResultMap">
        SELECT
        <include refid="Base_Column_List"/>
        FROM im_ai_chat_analytics
        ORDER BY stat_date DESC, count DESC
    </select>

</mapper>

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.roypowtech.smartanalysis.mapper.AiChatMessageMapper">

    <!-- 通用结果映射 -->
    <resultMap id="BaseResultMap" type="com.roypowtech.smartanalysis.entity.AiChatMessage">
        <id column="id" property="id"/>
        <result column="message_id" property="messageId"/>
        <result column="conversation_id" property="conversationId"/>
        <result column="user_id" property="userId"/>
        <result column="parent_message_id" property="parentMessageId"/>
        <result column="query" property="query"/>
        <result column="answer" property="answer"/>
        <result column="message_tokens" property="messageTokens"/>
        <result column="answer_tokens" property="answerTokens"/>
        <result column="total_tokens" property="totalTokens"/>
        <result column="latency_ms" property="latencyMs"/>
        <result column="feedback" property="feedback"/>
        <result column="feedback_reason" property="feedbackReason"/>
        <result column="message_type" property="messageType"/>
        <result column="file_urls" property="fileUrls"/>
        <result column="status" property="status"/>
        <result column="completed_at" property="completedAt"/>
        <result column="created_by" property="createdBy"/>
        <result column="updated_by" property="updatedBy"/>
        <result column="created_at" property="createdAt"/>
        <result column="updated_at" property="updatedAt"/>
    </resultMap>

    <!-- 通用查询列 -->
    <sql id="Base_Column_List">
        id, message_id, conversation_id, user_id, parent_message_id, query, answer,
        message_tokens, answer_tokens, total_tokens, latency_ms, feedback, feedback_reason,
        message_type, file_urls, status, completed_at, created_by, updated_by, created_at, updated_at
    </sql>

    <!-- 根据会话 ID 查询消息(分页) -->
    <select id="selectByConversation" resultMap="BaseResultMap">
        SELECT
        <include refid="Base_Column_List"/>
        FROM im_ai_chat_message
        WHERE conversation_id = #{conversationId}
        AND status != 0
        <if test="lastMessageId != null and lastMessageId != ''">
            AND id &lt; (SELECT id FROM im_ai_chat_message WHERE message_id = #{lastMessageId})
        </if>
        ORDER BY created_at ASC
        LIMIT #{limit}
    </select>

    <!-- 查询会话的最后一条消息 -->
    <select id="selectLastMessage" resultMap="BaseResultMap">
        SELECT
        <include refid="Base_Column_List"/>
        FROM im_ai_chat_message
        WHERE conversation_id = #{conversationId}
        AND status != 0
        ORDER BY created_at DESC
        LIMIT 1
    </select>

    <!-- 批量获取多个会话的最后一条消息 -->
    <select id="selectLastMessagesByConversationIds" resultMap="BaseResultMap">
        SELECT t.*
        FROM im_ai_chat_message t
        INNER JOIN (
            SELECT conversation_id, MAX(created_at) as max_time
            FROM im_ai_chat_message
            WHERE conversation_id IN
            <foreach item="id" collection="conversationIds" open="(" separator="," close=")">
                #{id}
            </foreach>
            AND status != 0
            GROUP BY conversation_id
        ) tmp ON t.conversation_id = tmp.conversation_id AND t.created_at = tmp.max_time
    </select>

    <!-- 更新消息反馈 -->
    <update id="updateFeedback">
        UPDATE im_ai_chat_message
        SET feedback = #{feedback},
            feedback_reason = #{reason},
            updated_by = #{userId},
            updated_at = CURRENT_TIMESTAMP
        WHERE message_id = #{messageId}
    </update>

    <!-- 根据消息 ID 查询 -->
    <select id="selectByMessageId" resultMap="BaseResultMap">
        SELECT
        <include refid="Base_Column_List"/>
        FROM im_ai_chat_message
        WHERE message_id = #{messageId}
    </select>

    <!-- 批量插入 -->
    <insert id="batchInsert">
        INSERT INTO im_ai_chat_message (
            message_id, conversation_id, user_id, query, answer,
            total_tokens, latency_ms, completed_at, created_by, created_at
        ) VALUES
        <foreach collection="messages" item="msg" separator=",">
            (#{msg.messageId}, #{msg.conversationId}, #{msg.userId}, #{msg.query}, #{msg.answer},
             #{msg.totalTokens}, #{msg.latencyMs}, #{msg.completedAt}, #{msg.createdBy}, NOW())
        </foreach>
    </insert>

    <!-- ==================== 基本 CRUD 操作 ==================== -->

    <!-- 插入消息 -->
    <insert id="insert" parameterType="com.roypowtech.smartanalysis.entity.AiChatMessage" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO im_ai_chat_message (
            message_id, conversation_id, user_id, parent_message_id, query, answer,
            message_tokens, answer_tokens, total_tokens, latency_ms, feedback, feedback_reason,
            message_type, file_urls, status, completed_at, created_by, updated_by, created_at, updated_at
        ) VALUES (
            #{messageId}, #{conversationId}, #{userId}, #{parentMessageId}, #{query}, #{answer},
            #{messageTokens}, #{answerTokens}, #{totalTokens}, #{latencyMs}, #{feedback}, #{feedbackReason},
            #{messageType}, #{fileUrls}, #{status}, #{completedAt}, #{createdBy}, #{updatedBy}, NOW(), NOW()
        )
    </insert>

    <!-- 更新消息 -->
    <update id="update" parameterType="com.roypowtech.smartanalysis.entity.AiChatMessage">
        UPDATE im_ai_chat_message
        <set>
            <if test="conversationId != null">conversation_id = #{conversationId},</if>
            <if test="userId != null">user_id = #{userId},</if>
            <if test="parentMessageId != null">parent_message_id = #{parentMessageId},</if>
            <if test="query != null">query = #{query},</if>
            <if test="answer != null">answer = #{answer},</if>
            <if test="messageTokens != null">message_tokens = #{messageTokens},</if>
            <if test="answerTokens != null">answer_tokens = #{answerTokens},</if>
            <if test="totalTokens != null">total_tokens = #{totalTokens},</if>
            <if test="latencyMs != null">latency_ms = #{latencyMs},</if>
            <if test="feedback != null">feedback = #{feedback},</if>
            <if test="feedbackReason != null">feedback_reason = #{feedbackReason},</if>
            <if test="messageType != null">message_type = #{messageType},</if>
            <if test="fileUrls != null">file_urls = #{fileUrls},</if>
            <if test="status != null">status = #{status},</if>
            <if test="completedAt != null">completed_at = #{completedAt},</if>
            <if test="updatedBy != null">updated_by = #{updatedBy},</if>
            updated_at = NOW()
        </set>
        WHERE message_id = #{messageId}
    </update>

    <!-- 删除消息(逻辑删除) -->
    <update id="deleteById" parameterType="java.lang.Long">
        UPDATE im_ai_chat_message
        SET status = 0,
            updated_by = 'SYSTEM',
            updated_at = NOW()
        WHERE id = #{id}
    </update>

    <!-- 批量删除消息 -->
    <update id="batchDelete" parameterType="java.util.List">
        UPDATE im_ai_chat_message
        SET status = 0,
            updated_by = 'SYSTEM',
            updated_at = NOW()
        WHERE id IN
        <foreach collection="ids" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </update>

    <!-- 根据 ID 查询消息 -->
    <select id="selectById" parameterType="java.lang.Long" resultMap="BaseResultMap">
        SELECT
        <include refid="Base_Column_List"/>
        FROM im_ai_chat_message
        WHERE id = #{id}
        AND status != 0
    </select>

    <!-- 查询所有消息 -->
    <select id="selectAll" resultMap="BaseResultMap">
        SELECT
        <include refid="Base_Column_List"/>
        FROM im_ai_chat_message
        WHERE status != 0
        ORDER BY created_at DESC
    </select>

</mapper>

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.roypowtech.smartanalysis.mapper.AiConversationMapper">

    <!-- 通用结果映射 -->
    <resultMap id="BaseResultMap" type="com.roypowtech.smartanalysis.entity.AiConversation">
        <id column="id" property="id"/>
        <result column="conversation_id" property="conversationId"/>
        <result column="user_id" property="userId"/>
        <result column="device_sn" property="deviceSn"/>
        <result column="battery_id" property="batteryId"/>
        <result column="title" property="title"/>
        <result column="status" property="status"/>
        <result column="last_message_time" property="lastMessageTime"/>
        <result column="message_count" property="messageCount"/>
        <result column="source" property="source"/>
        <result column="created_by" property="createdBy"/>
        <result column="updated_by" property="updatedBy"/>
        <result column="created_at" property="createdAt"/>
        <result column="updated_at" property="updatedAt"/>
    </resultMap>

    <!-- 通用查询列 -->
    <sql id="Base_Column_List">
        id, conversation_id, user_id, device_sn, battery_id, title, status, 
        last_message_time, message_count, source, created_by, updated_by, created_at, updated_at
    </sql>

    <!-- 根据会话 ID 查询 -->
    <select id="selectByConversationId" resultMap="BaseResultMap">
        SELECT
        <include refid="Base_Column_List"/>
        FROM im_ai_conversation
        WHERE conversation_id = #{conversationId}
        AND status != 0
    </select>

    <!-- 根据用户 ID 查询会话列表 -->
    <select id="selectByUserId" resultMap="BaseResultMap">
        SELECT
        <include refid="Base_Column_List"/>
        FROM im_ai_conversation
        WHERE user_id = #{userId}
        AND status != 0
        ORDER BY last_message_time DESC, created_at DESC
        LIMIT #{limit}
    </select>

    <!-- 增加消息计数 -->
    <update id="incrementMessageCount">
        UPDATE im_ai_conversation
        SET message_count = message_count + 1,
            last_message_time = CURRENT_TIMESTAMP,
            updated_at = CURRENT_TIMESTAMP
        WHERE conversation_id = #{conversationId}
    </update>

    <!-- 更新最后消息时间 -->
    <update id="updateLastMessageTime">
        UPDATE im_ai_conversation
        SET last_message_time = #{lastMessageTime},
            updated_at = CURRENT_TIMESTAMP
        WHERE conversation_id = #{conversationId}
    </update>

    <!-- 根据设备和电池查询会话 -->
    <select id="selectByDeviceAndBattery" resultMap="BaseResultMap">
        SELECT
        <include refid="Base_Column_List"/>
        FROM im_ai_conversation
        WHERE status = #{status}
        <if test="deviceSn != null and deviceSn != ''">
            AND device_sn = #{deviceSn}
        </if>
        <if test="batteryId != null">
            AND battery_id = #{batteryId}
        </if>
        ORDER BY last_message_time DESC
    </select>

    <!-- ==================== 基本 CRUD 操作 ==================== -->

    <!-- 插入会话 -->
    <insert id="insert" parameterType="com.roypowtech.smartanalysis.entity.AiConversation" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO im_ai_conversation (
            conversation_id, user_id, device_sn, battery_id, title, status,
            last_message_time, message_count, source, created_by, updated_by, created_at, updated_at
        ) VALUES (
            #{conversationId}, #{userId}, #{deviceSn}, #{batteryId}, #{title}, #{status},
            #{lastMessageTime}, #{messageCount}, #{source}, #{createdBy}, #{updatedBy}, NOW(), NOW()
        )
    </insert>

    <!-- 更新会话 -->
    <update id="update" parameterType="com.roypowtech.smartanalysis.entity.AiConversation">
        UPDATE im_ai_conversation
        <set>
            <if test="conversationId != null">conversation_id = #{conversationId},</if>
            <if test="userId != null">user_id = #{userId},</if>
            <if test="deviceSn != null">device_sn = #{deviceSn},</if>
            <if test="batteryId != null">battery_id = #{batteryId},</if>
            <if test="title != null">title = #{title},</if>
            <if test="status != null">status = #{status},</if>
            <if test="lastMessageTime != null">last_message_time = #{lastMessageTime},</if>
            <if test="messageCount != null">message_count = #{messageCount},</if>
            <if test="source != null">source = #{source},</if>
            <if test="updatedBy != null">updated_by = #{updatedBy},</if>
            updated_at = NOW()
        </set>
        WHERE id = #{id}
    </update>

    <!-- 删除会话(逻辑删除) -->
    <update id="deleteById" parameterType="java.lang.Long">
        UPDATE im_ai_conversation
        SET status = 0,
            updated_by = 'SYSTEM',
            updated_at = NOW()
        WHERE id = #{id}
    </update>

    <!-- 批量删除会话 -->
    <update id="batchDelete" parameterType="java.util.List">
        UPDATE im_ai_conversation
        SET status = 0,
            updated_by = 'SYSTEM',
            updated_at = NOW()
        WHERE id IN
        <foreach collection="ids" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </update>

    <!-- 根据 ID 查询会话 -->
    <select id="selectById" parameterType="java.lang.Long" resultMap="BaseResultMap">
        SELECT
        <include refid="Base_Column_List"/>
        FROM im_ai_conversation
        WHERE id = #{id}
        AND status != 0
    </select>

    <!-- 查询所有会话 -->
    <select id="selectAll" resultMap="BaseResultMap">
        SELECT
        <include refid="Base_Column_List"/>
        FROM im_ai_conversation
        WHERE status != 0
        ORDER BY last_message_time DESC
    </select>

</mapper>

连接配置:

XML 复制代码
# dify平台接入配置
dify:
  base-url: http://127.0.0.1/v1  # Dify服务地址
  api-key: ${DIFY_API_KEY:app-your-api-key}  # 从环境变量读取
  app-type: chat  # chat 或 workflow
  timeout: 60s
  max-retries: 3

  # 连接池配置
  connection:
    max-total: 100
    max-per-route: 20
    connection-timeout: 5000
    read-timeout: 60000

# ai 对话 相关配置
ai-chat:
  # 限流配置(防止刷接口)
  rate-limit:
    max-requests-per-minute: 30
    max-tokens-per-day: 10000

  # 敏感词过滤(可选)
  sensitive-words:
    - "密码"
    - "secret"

  # 上下文缓存
  context:
    ttl-hours: 24
    max-context-messages: 10  # 保留最近10轮对话

三.数据库设计

sql 复制代码
#1.主表
CREATE TABLE `im_ai_conversation` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `conversation_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Dify生成的会话ID(如: conv-abc123)',
  `user_id` bigint NOT NULL COMMENT 'IronMan平台用户ID',
  `device_sn` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '关联设备序列号(可选)',
  `battery_id` bigint DEFAULT NULL COMMENT '关联电池ID(可选)',
  `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '新会话' COMMENT '会话标题(AI自动生成或用户修改)',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态: 1-正常, 2-已归档, 0-已删除',
  `last_message_time` timestamp NULL DEFAULT NULL COMMENT '最后消息时间',
  `message_count` int NOT NULL DEFAULT '0' COMMENT '消息总数(避免频繁COUNT)',
  `source` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT 'web' COMMENT '来源: web/app/mini_program',
  `created_by` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人(用户账号)',
  `updated_by` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '最后更新人',
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_conversation_id` (`conversation_id`) COMMENT 'Dify会话ID唯一',
  KEY `idx_user_id` (`user_id`) COMMENT '用户查询会话列表',
  KEY `idx_device_sn` (`device_sn`) COMMENT '设备关联查询',
  KEY `idx_last_message_time` (`last_message_time`) COMMENT '用于排序最近会话',
  KEY `idx_created_at` (`created_at`) COMMENT '时间范围查询'
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='智能-AI对话会话表。存储用户与AI助手的会话元数据,支持设备上下文关联。';

#2.次表
CREATE TABLE `im_ai_chat_message` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `message_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Dify消息ID(如: msg-xyz789)',
  `conversation_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '关联会话ID(对应im_ai_conversation)',
  `user_id` bigint NOT NULL COMMENT '用户ID(冗余字段,避免联表查询)',
  `parent_message_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '父消息ID(支持引用回复)',
  `query` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '用户提问内容(原始输入)',
  `answer` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'AI回答内容(流式接收完成后更新)',
  `message_tokens` int NOT NULL DEFAULT '0' COMMENT '提问消耗的token数',
  `answer_tokens` int NOT NULL DEFAULT '0' COMMENT '回答消耗的token数',
  `total_tokens` int NOT NULL DEFAULT '0' COMMENT '总消耗token数(冗余计算字段)',
  `latency_ms` int NOT NULL DEFAULT '0' COMMENT '响应延迟(毫秒),从发送到接收完成的时间',
  `feedback` tinyint NOT NULL DEFAULT '0' COMMENT '用户反馈: 0-无, 1-点赞, 2-点踩',
  `feedback_reason` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '反馈原因(点踩时填写)',
  `message_type` tinyint NOT NULL DEFAULT '1' COMMENT '消息类型: 1-文本, 2-图片, 3-文件, 4-语音',
  `file_urls` json DEFAULT NULL COMMENT '关联文件URL数组(JSON格式: [{"type":"image","url":"xxx"}])',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态: 1-正常, 2-生成中, 0-已删除',
  `completed_at` timestamp NULL DEFAULT NULL COMMENT '流式响应完成时间(用于计算实际生成耗时)',
  `created_by` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
  `updated_by` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '最后更新人(更新反馈时)',
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_message_id` (`message_id`) COMMENT 'Dify消息ID唯一',
  KEY `idx_conversation_id` (`conversation_id`) COMMENT '会话消息查询',
  KEY `idx_user_id_created` (`user_id`,`created_at`) COMMENT '用户历史记录分页查询',
  KEY `idx_created_at` (`created_at`) COMMENT '时间范围清理/统计',
  KEY `idx_feedback` (`feedback`) COMMENT '筛选有反馈的消息(用于质量分析)'
) ENGINE=InnoDB AUTO_INCREMENT=59 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='智能-AI对话消息表。存储完整的问答内容、Token消耗、用户反馈等。支持流式生成状态追踪。';

#3.统计分析表
CREATE TABLE `im_ai_chat_analytics` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `stat_date` date NOT NULL COMMENT '统计日期(YYYY-MM-DD)',
  `question_pattern` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '问题模式(标准化后的问法,如: "SOH衰减原因")',
  `question_keyword` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '核心关键词(如: SOH)',
  `question_category` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT 'OTHER' COMMENT '问题分类: SOH/SOC/ALERT/TEMPERATURE/DISCHARGE/CHARGE/OTHER',
  `count` int NOT NULL DEFAULT '0' COMMENT '当日该问题出现次数',
  `unique_users` int NOT NULL DEFAULT '0' COMMENT '当日询问该问题的独立用户数(去重)',
  `device_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '关联设备类型(如: LFP100Ah)',
  `avg_response_time` int DEFAULT NULL COMMENT '平均响应时长(ms)',
  `satisfaction_rate` decimal(5,2) DEFAULT NULL COMMENT '满意度(点赞率),如 95.50',
  `trend` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '趋势: UP/DOWN/FLAT(对比昨日)',
  `created_by` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT 'system' COMMENT '创建人(通常为system)',
  `updated_by` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '最后更新人',
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_stat_date_pattern` (`stat_date`,`question_pattern`) COMMENT '每天每个问题模式只记录一条',
  KEY `idx_stat_date` (`stat_date`) COMMENT '日期范围查询',
  KEY `idx_category` (`question_category`) COMMENT '分类统计',
  KEY `idx_count` (`count`) COMMENT '高频问题排序',
  KEY `idx_device_type` (`device_type`) COMMENT '设备类型分析'
) ENGINE=InnoDB AUTO_INCREMENT=26 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='智能-AI问答热点统计表。用于分析用户最常问的问题、热点趋势、服务质量监控。每日汇总生成。';
相关推荐
小白橘颂2 小时前
【C语言】基础概念梳理(一)
c语言·开发语言·stm32·单片机·mcu·物联网·51单片机
半瓶榴莲奶^_^2 小时前
java模式
java·开发语言
sword devil9002 小时前
TRAE:agent团队
开发语言
co_wait2 小时前
【c 语言】linux下gcc编译工具的使用
linux·c语言·开发语言
2301_815482932 小时前
C++编译期矩阵运算
开发语言·c++·算法
☆5662 小时前
C++中的类型擦除技术
开发语言·c++·算法
m0_569881472 小时前
C++与自动驾驶系统
开发语言·c++·算法
天理小学渣2 小时前
JavaScript_基础教程_自学笔记
开发语言·javascript·笔记
angerdream2 小时前
最新版vue3+TypeScript开发入门到实战教程之生命周期函数
javascript·vue.js