diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index 569fb05..ebc5102 100644 --- a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java +++ b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java @@ -20,6 +20,13 @@ public class DatabaseInitConfig { @PostConstruct public void init() { try { + // 检查 chat_message 表是否存在 + boolean chatMessageTableExists = checkTableExists("chat_message"); + if (!chatMessageTableExists) { + log.info("创建聊天消息表 chat_message"); + createChatMessageTable(); + } + // 检查 knowledge_category 表是否存在 boolean categoryTableExists = checkTableExists("knowledge_category"); if (!categoryTableExists) { @@ -55,6 +62,27 @@ public class DatabaseInitConfig { } } + private void createChatMessageTable() { + String sql = """ + CREATE TABLE IF NOT EXISTS chat_message ( + id BIGSERIAL PRIMARY KEY, + conversation_id VARCHAR(64) NOT NULL, + message_type VARCHAR(20) NOT NULL, + content TEXT NOT NULL, + metadata JSONB DEFAULT '{}' NOT NULL, + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + is_delete BOOLEAN DEFAULT FALSE NOT NULL + ) + """; + jdbcTemplate.execute(sql); + + // 创建索引 + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_chat_message_conversation_id ON chat_message (conversation_id)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_chat_message_create_time ON chat_message (create_time DESC)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_chat_message_type ON chat_message (message_type)"); + } + private void createCategoryTable() { String sql = """ CREATE TABLE IF NOT EXISTS knowledge_category ( diff --git a/src/main/java/com/wok/supportbot/controller/ConversationController.java b/src/main/java/com/wok/supportbot/controller/ConversationController.java new file mode 100644 index 0000000..d80ce44 --- /dev/null +++ b/src/main/java/com/wok/supportbot/controller/ConversationController.java @@ -0,0 +1,175 @@ +package com.wok.supportbot.controller; + +import com.wok.supportbot.service.ConversationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 会话管理控制器 + * 提供会话列表、详情、消息、删除、导出等 API + */ +@RestController +public class ConversationController { + + @Autowired + private ConversationService conversationService; + + // ==================== 会话列表 ==================== + + /** + * 获取会话列表(分页) + * + * @param page 页码(默认1) + * @param size 每页大小(默认10) + * @param keyword 搜索关键词(可选,按会话ID或消息内容模糊匹配) + * @return 分页会话列表 + */ + @GetMapping("/conversation/list") + public ResponseEntity> listConversations( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String keyword) { + try { + Map result = conversationService.listConversations(page, size, keyword); + Map data = new java.util.HashMap<>(); + data.put("success", true); + data.put("data", result.get("records")); + data.put("total", result.get("total")); + data.put("page", result.get("page")); + data.put("size", result.get("size")); + data.put("pages", result.get("pages")); + return ResponseEntity.ok(data); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "查询失败:" + e.getMessage() + )); + } + } + + // ==================== 会话详情 ==================== + + /** + * 获取会话详情 + * + * @param conversationId 会话ID + * @return 会话详情 + */ + @GetMapping("/conversation/{id}") + public ResponseEntity> getConversationDetail(@PathVariable("id") String conversationId) { + try { + Map detail = conversationService.getConversationDetail(conversationId); + if ((int) detail.get("messageCount") == 0) { + return ResponseEntity.status(404).body(Map.of( + "success", false, + "message", "会话不存在或无任何消息" + )); + } + return ResponseEntity.ok(Map.of( + "success", true, + "data", detail + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "查询失败:" + e.getMessage() + )); + } + } + + /** + * 获取会话消息列表 + * + * @param conversationId 会话ID + * @return 消息列表 + */ + @GetMapping("/conversation/{id}/messages") + public ResponseEntity> getConversationMessages(@PathVariable("id") String conversationId) { + try { + List> messages = conversationService.getConversationMessages(conversationId); + return ResponseEntity.ok(Map.of( + "success", true, + "data", messages, + "total", messages.size() + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "查询失败:" + e.getMessage() + )); + } + } + + // ==================== 删除会话 ==================== + + /** + * 删除会话(逻辑删除该会话下的所有消息) + * + * @param conversationId 会话ID + * @return 删除结果 + */ + @DeleteMapping("/conversation/{id}") + public ResponseEntity> deleteConversation(@PathVariable("id") String conversationId) { + try { + int count = conversationService.deleteConversation(conversationId); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "会话删除成功", + "deletedMessages", count + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "删除失败:" + e.getMessage() + )); + } + } + + // ==================== 导出会话 ==================== + + /** + * 导出会话记录为文本 + * + * @param conversationId 会话ID + * @return 格式化后的会话文本 + */ + @GetMapping(value = "/conversation/{id}/export", produces = MediaType.TEXT_PLAIN_VALUE + ";charset=UTF-8") + public ResponseEntity exportConversation(@PathVariable("id") String conversationId) { + try { + String content = conversationService.exportConversation(conversationId); + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=conversation_" + conversationId + ".txt") + .body(content); + } catch (Exception e) { + return ResponseEntity.status(500).body("导出失败:" + e.getMessage()); + } + } + + // ==================== 统计 ==================== + + /** + * 获取会话统计信息 + * + * @return 统计信息 + */ + @GetMapping("/conversation/stats") + public ResponseEntity> getConversationStats() { + try { + Map stats = conversationService.getConversationStats(); + return ResponseEntity.ok(Map.of( + "success", true, + "data", stats + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "查询统计失败:" + e.getMessage() + )); + } + } +} diff --git a/src/main/java/com/wok/supportbot/service/ConversationService.java b/src/main/java/com/wok/supportbot/service/ConversationService.java new file mode 100644 index 0000000..8f387c2 --- /dev/null +++ b/src/main/java/com/wok/supportbot/service/ConversationService.java @@ -0,0 +1,291 @@ +package com.wok.supportbot.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.wok.supportbot.dao.ChatMessageMapper; +import com.wok.supportbot.entity.ChatMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 会话管理服务 + * 提供会话列表、详情、消息列表、删除、导出等功能 + */ +@Service +@Slf4j +public class ConversationService { + + @Autowired + private ChatMessageMapper chatMessageMapper; + + @Autowired + private JdbcTemplate jdbcTemplate; + + // ==================== 会话列表 ==================== + + /** + * 获取会话列表(分页) + * + * @param page 页码(从1开始) + * @param size 每页大小 + * @param keyword 搜索关键词(按会话ID或消息内容模糊匹配) + * @return 分页会话列表 + */ + public Map listConversations(int page, int size, String keyword) { + // 构建基础 SQL 条件 + StringBuilder whereClause = new StringBuilder("WHERE is_delete = false"); + List params = new ArrayList<>(); + + if (keyword != null && !keyword.isEmpty()) { + // 先找出包含关键词的 conversation_id + String matchSql = "SELECT DISTINCT conversation_id FROM chat_message " + + "WHERE is_delete = false AND (conversation_id ILIKE ? OR content ILIKE ?)"; + List matchedIds = jdbcTemplate.queryForList(matchSql, String.class, + "%" + keyword + "%", "%" + keyword + "%"); + + if (matchedIds.isEmpty()) { + // 无匹配结果,返回空列表 + Map emptyResult = new LinkedHashMap<>(); + emptyResult.put("records", List.of()); + emptyResult.put("total", 0L); + emptyResult.put("page", page); + emptyResult.put("size", size); + emptyResult.put("pages", 0L); + return emptyResult; + } + + // 构建 IN 子句 + String inClause = matchedIds.stream() + .map(id -> "'" + id.replace("'", "''") + "'") + .collect(Collectors.joining(",")); + whereClause.append(" AND conversation_id IN (").append(inClause).append(")"); + } + + // 查询总会话数 + String countSql = "SELECT COUNT(DISTINCT conversation_id) FROM chat_message " + whereClause; + Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray()); + if (total == null) total = 0L; + + // 查询会话列表(带统计信息) + String listSql = """ + SELECT + conversation_id, + COUNT(*) as message_count, + MIN(create_time) as first_message_time, + MAX(create_time) as last_message_time, + (SELECT content FROM chat_message cm2 + WHERE cm2.conversation_id = cm1.conversation_id AND cm2.is_delete = false + ORDER BY cm2.create_time DESC LIMIT 1) as last_message_preview + FROM chat_message cm1 + """ + whereClause + """ + GROUP BY conversation_id + ORDER BY last_message_time DESC + LIMIT ? OFFSET ? + """; + + List> records = jdbcTemplate.queryForList(listSql, + size, (page - 1) * size); + + // 格式化结果 + List> formattedRecords = new ArrayList<>(); + for (Map record : records) { + Map formatted = new LinkedHashMap<>(); + formatted.put("conversationId", record.get("conversation_id")); + formatted.put("messageCount", ((Number) record.get("message_count")).intValue()); + formatted.put("firstMessageTime", record.get("first_message_time")); + formatted.put("lastMessageTime", record.get("last_message_time")); + String preview = (String) record.get("last_message_preview"); + formatted.put("lastMessagePreview", preview != null && preview.length() > 100 + ? preview.substring(0, 100) + "..." : preview); + formattedRecords.add(formatted); + } + + Map result = new LinkedHashMap<>(); + result.put("records", formattedRecords); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("pages", (total + size - 1) / size); + return result; + } + + // ==================== 会话详情 ==================== + + /** + * 获取会话详情 + * + * @param conversationId 会话ID + * @return 会话详情 + */ + public Map getConversationDetail(String conversationId) { + // 查询会话统计信息 + String statsSql = """ + SELECT + COUNT(*) as message_count, + COUNT(*) FILTER (WHERE message_type = 'USER') as user_message_count, + COUNT(*) FILTER (WHERE message_type = 'ASSISTANT') as assistant_message_count, + MIN(create_time) as first_message_time, + MAX(create_time) as last_message_time + FROM chat_message + WHERE conversation_id = ? AND is_delete = false + """; + + Map stats; + try { + stats = jdbcTemplate.queryForMap(statsSql, conversationId); + } catch (Exception e) { + stats = new HashMap<>(); + stats.put("message_count", 0); + stats.put("user_message_count", 0); + stats.put("assistant_message_count", 0); + } + + Map detail = new LinkedHashMap<>(); + detail.put("conversationId", conversationId); + detail.put("messageCount", ((Number) stats.get("message_count")).intValue()); + detail.put("userMessageCount", ((Number) stats.get("user_message_count")).intValue()); + detail.put("assistantMessageCount", ((Number) stats.get("assistant_message_count")).intValue()); + detail.put("firstMessageTime", stats.get("first_message_time")); + detail.put("lastMessageTime", stats.get("last_message_time")); + + return detail; + } + + // ==================== 会话消息 ==================== + + /** + * 获取会话消息列表 + * + * @param conversationId 会话ID + * @return 消息列表 + */ + public List> getConversationMessages(String conversationId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ChatMessage::getConversationId, conversationId) + .orderByAsc(ChatMessage::getCreateTime); + + List messages = chatMessageMapper.selectList(wrapper); + + List> result = new ArrayList<>(); + for (ChatMessage msg : messages) { + Map item = new LinkedHashMap<>(); + item.put("id", msg.getId()); + item.put("conversationId", msg.getConversationId()); + item.put("messageType", msg.getMessageType()); + item.put("content", msg.getContent()); + item.put("metadata", msg.getMetadata()); + item.put("createTime", msg.getCreateTime()); + result.add(item); + } + return result; + } + + // ==================== 删除会话 ==================== + + /** + * 删除会话(逻辑删除该会话下的所有消息) + * + * @param conversationId 会话ID + * @return 删除的消息数量 + */ + public int deleteConversation(String conversationId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ChatMessage::getConversationId, conversationId); + + // MyBatis Plus 的 @TableLogic 会自动处理逻辑删除 + int count = chatMessageMapper.delete(wrapper); + log.info("删除会话: conversationId={}, 删除消息数={}", conversationId, count); + return count; + } + + // ==================== 导出会话 ==================== + + /** + * 导出会话记录为文本格式 + * + * @param conversationId 会话ID + * @return 格式化后的会话文本 + */ + public String exportConversation(String conversationId) { + Map detail = getConversationDetail(conversationId); + List> messages = getConversationMessages(conversationId); + + if (messages.isEmpty()) { + return "会话不存在或无任何消息"; + } + + StringBuilder sb = new StringBuilder(); + sb.append("====================================\n"); + sb.append("会话导出记录\n"); + sb.append("====================================\n"); + sb.append("会话ID: ").append(conversationId).append("\n"); + sb.append("消息总数: ").append(detail.get("messageCount")).append("\n"); + sb.append("首次消息时间: ").append(detail.get("firstMessageTime")).append("\n"); + sb.append("最后消息时间: ").append(detail.get("lastMessageTime")).append("\n"); + sb.append("====================================\n\n"); + + for (Map msg : messages) { + String type = String.valueOf(msg.get("messageType")); + String content = String.valueOf(msg.get("content")); + String time = String.valueOf(msg.get("createTime")); + + sb.append("[").append(time).append("] "); + switch (type) { + case "USER" -> sb.append("用户"); + case "ASSISTANT" -> sb.append("AI助手"); + case "SYSTEM" -> sb.append("系统"); + default -> sb.append(type); + } + sb.append("\n"); + sb.append(content).append("\n\n"); + } + + sb.append("====================================\n"); + sb.append("导出时间: ").append(new Date()).append("\n"); + sb.append("====================================\n"); + + return sb.toString(); + } + + // ==================== 统计 ==================== + + /** + * 获取会话统计信息 + * + * @return 统计信息 + */ + public Map getConversationStats() { + // 总会话数(不重复 conversation_id) + String totalSql = "SELECT COUNT(DISTINCT conversation_id) FROM chat_message WHERE is_delete = false"; + Long totalConversations = jdbcTemplate.queryForObject(totalSql, Long.class); + if (totalConversations == null) totalConversations = 0L; + + // 总消息数 + String messageSql = "SELECT COUNT(*) FROM chat_message WHERE is_delete = false"; + Long totalMessages = jdbcTemplate.queryForObject(messageSql, Long.class); + if (totalMessages == null) totalMessages = 0L; + + // 今日消息数 + String todaySql = "SELECT COUNT(*) FROM chat_message WHERE is_delete = false AND create_time >= CURRENT_DATE"; + Long todayMessages = jdbcTemplate.queryForObject(todaySql, Long.class); + if (todayMessages == null) todayMessages = 0L; + + // 今日会话数 + String todayConvSql = "SELECT COUNT(DISTINCT conversation_id) FROM chat_message WHERE is_delete = false AND create_time >= CURRENT_DATE"; + Long todayConversations = jdbcTemplate.queryForObject(todayConvSql, Long.class); + if (todayConversations == null) todayConversations = 0L; + + Map stats = new LinkedHashMap<>(); + stats.put("totalConversations", totalConversations); + stats.put("totalMessages", totalMessages); + stats.put("todayConversations", todayConversations); + stats.put("todayMessages", todayMessages); + + return stats; + } +} diff --git a/src/main/resources/static/components/ConversationManager.js b/src/main/resources/static/components/ConversationManager.js new file mode 100644 index 0000000..b194abb --- /dev/null +++ b/src/main/resources/static/components/ConversationManager.js @@ -0,0 +1,224 @@ +/** + * 💬 会话管理组件 + * 展示会话列表、消息详情、删除、导出功能 + */ +import { ref } from 'vue' +import { listConversations, deleteConversation, getConversationMessages, getConversationStats, exportConversation } from '../js/api.js' +import { toast, formatDate } from '../js/utils.js' + +export default { + template: ` +
+

💬 会话列表

+ + +
+
+
{{ stats.totalConversations || 0 }}
+
总会话数
+
+
+
{{ stats.totalMessages || 0 }}
+
总消息数
+
+
+
{{ stats.todayConversations || 0 }}
+
今日会话
+
+
+
{{ stats.todayMessages || 0 }}
+
今日消息
+
+
+ + +
+ + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + +
会话ID消息数最后消息时间最后消息预览操作
暂无会话记录
+ {{ c.conversationId }} + {{ c.messageCount }}{{ formatDate(c.lastMessageTime) }} + {{ c.lastMessagePreview || '-' }} + + + + +
+
+ + + +
+ + + + `, + setup() { + const conversations = ref([]) + const page = ref(1) + const pages = ref(1) + const total = ref(0) + const keyword = ref('') + const stats = ref(null) + + // 消息详情弹窗 + const messageModal = ref({ + visible: false, + conversationId: '', + messages: [] + }) + + async function load(p = 1) { + page.value = p + try { + const json = await listConversations(p, 10, keyword.value || undefined) + if (json.success) { + conversations.value = json.data || [] + total.value = json.total || 0 + pages.value = json.pages || 1 + } else { + toast(json.message || '查询失败', 'error') + } + } catch (e) { + toast('加载会话失败:' + e.message, 'error') + } + } + + async function loadStats() { + try { + const json = await getConversationStats() + if (json.success) { + stats.value = json.data + } + } catch (e) { + console.error('加载会话统计失败', e) + } + } + + async function viewMessages(conversationId) { + try { + const json = await getConversationMessages(conversationId) + if (json.success) { + messageModal.value.conversationId = conversationId + messageModal.value.messages = json.data || [] + messageModal.value.visible = true + } else { + toast(json.message || '加载消息失败', 'error') + } + } catch (e) { + toast('加载消息失败:' + e.message, 'error') + } + } + + function closeMessageModal() { + messageModal.value.visible = false + messageModal.value.conversationId = '' + messageModal.value.messages = [] + } + + async function remove(conversationId) { + if (!confirm('确定删除此会话?该会话下的所有消息将被逻辑删除')) return + try { + const json = await deleteConversation(conversationId) + if (json.success) { + toast(`会话删除成功,共删除 ${json.deletedMessages || 0} 条消息`, 'success') + load(page.value) + loadStats() + } else { + toast(json.message || '删除失败', 'error') + } + } catch (e) { + toast('删除失败:' + e.message, 'error') + } + } + + async function downloadExport(conversationId) { + try { + const content = await exportConversation(conversationId) + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `conversation_${conversationId}.txt` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + toast('导出成功', 'success') + } catch (e) { + toast('导出失败:' + e.message, 'error') + } + } + + // 初始加载 + load() + loadStats() + + return { + conversations, page, pages, total, keyword, stats, messageModal, + load, loadStats, viewMessages, closeMessageModal, remove, downloadExport, formatDate + } + } +} diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index d4b3783..ed808fc 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -132,5 +132,11 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(- .badge-post { background:#fef3c7; color:#b45309; } .endpoint-info { display:flex; align-items:center; gap:6px; margin-bottom:8px; flex-wrap:wrap; } +/* ==================== 消息列表样式(会话管理) ==================== */ +.msg-item { border:1px solid var(--border); } +.msg-item.msg-user { background:#f0f9ff; border-left:3px solid var(--primary); } +.msg-item.msg-assistant { background:#f0fdf4; border-left:3px solid var(--success); } +.msg-item.msg-system { background:#f9fafb; border-left:3px solid var(--sub); } + /* ==================== 响应式 ==================== */ @media(max-width:768px) { .tabs { overflow-x:auto; } .tab-btn { padding:12px 16px; font-size:13px; } .stream-compare { grid-template-columns:1fr; } .stat-grid { grid-template-columns:1fr 1fr; } } diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js index cccbafb..9822006 100644 --- a/src/main/resources/static/js/api.js +++ b/src/main/resources/static/js/api.js @@ -337,3 +337,50 @@ export function updateCategory(id, data) { export function deleteCategory(id) { return deleteJSON(`/category/${id}`) } + +// ==================== 会话管理 ==================== + +/** + * 会话列表(分页) + */ +export function listConversations(page = 1, size = 10, keyword) { + let path = `/conversation/list?page=${page}&size=${size}` + if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` + return getJSON(path) +} + +/** + * 会话详情 + */ +export function getConversationDetail(conversationId) { + return getJSON(`/conversation/${conversationId}`) +} + +/** + * 会话消息列表 + */ +export function getConversationMessages(conversationId) { + return getJSON(`/conversation/${conversationId}/messages`) +} + +/** + * 删除会话 + */ +export function deleteConversation(conversationId) { + return deleteJSON(`/conversation/${conversationId}`) +} + +/** + * 导出会话记录 + */ +export function exportConversation(conversationId) { + return fetch(API_BASE + `/conversation/${conversationId}/export`) + .then(res => res.text()) +} + +/** + * 会话统计 + */ +export function getConversationStats() { + return getJSON('/conversation/stats') +} diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index 7c58407..314a147 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -14,6 +14,7 @@ import CategoryManager from '../components/CategoryManager.js' import DocList from '../components/DocList.js' import DocUpload from '../components/DocUpload.js' import DocDetail from '../components/DocDetail.js' +import ConversationManager from '../components/ConversationManager.js' const app = createApp({ setup() { @@ -50,6 +51,9 @@ const app = createApp({ + @@ -73,6 +77,11 @@ const app = createApp({ + +
+ +
+ @@ -91,5 +100,6 @@ app.component('category-manager', CategoryManager) app.component('doc-list', DocList) app.component('doc-upload', DocUpload) app.component('doc-detail', DocDetail) +app.component('conversation-manager', ConversationManager) app.mount('#app')