一.前端代码
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 < (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问答热点统计表。用于分析用户最常问的问题、热点趋势、服务质量监控。每日汇总生成。';