From 1ed6c8fb4346de214e5270098dee59652a7f3f04 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Tue, 11 Aug 2026 15:16:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8F=8D=E9=A6=88=E8=BF=90?= =?UTF-8?q?=E8=90=A5=E7=9C=8B=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/faq.ts | 4 + frontend/src/api/feedback.ts | 9 + frontend/src/router/index.ts | 2 + frontend/src/stores/navigation.ts | 1 + frontend/src/views/ChatPanel.vue | 98 +++++- frontend/src/views/ConversationManager.vue | 187 ++++++++++- frontend/src/views/FaqManager.vue | 12 +- frontend/src/views/FeedbackOps.vue | 299 ++++++++++++++++++ .../supportbot/config/DatabaseInitConfig.java | 140 ++++++-- .../supportbot/controller/FaqController.java | 145 +++++++++ .../controller/MessageFeedbackController.java | 82 +++++ .../supportbot/dao/FaqFeedbackLinkMapper.java | 34 ++ .../supportbot/entity/FaqFeedbackLink.java | 94 ++++++ .../wok/supportbot/entity/KnowledgeFaq.java | 9 +- .../supportbot/entity/MessageFeedback.java | 19 ++ .../wok/supportbot/service/FaqService.java | 198 +++++++++++- .../service/MessageFeedbackService.java | 184 +++++++++++ src/main/resources/init-database.sql | 49 ++- 18 files changed, 1526 insertions(+), 40 deletions(-) create mode 100644 frontend/src/views/FeedbackOps.vue create mode 100644 src/main/java/com/wok/supportbot/dao/FaqFeedbackLinkMapper.java create mode 100644 src/main/java/com/wok/supportbot/entity/FaqFeedbackLink.java diff --git a/frontend/src/api/faq.ts b/frontend/src/api/faq.ts index f4c80f5..59eaf9f 100644 --- a/frontend/src/api/faq.ts +++ b/frontend/src/api/faq.ts @@ -15,3 +15,7 @@ export function toggleFaqStatus(id: string, status: string): Promise { return request.post('/faq/batch-import', data).then(r => r.data) } export function exportFaqs(): Promise { return request.get('/faq/export').then(r => r.data) } export function getFaqStats(): Promise { return request.get('/faq/stats').then(r => r.data) } +export function createFaqFromFeedback(data: any): Promise { return request.post('/faq/from-feedback', data).then(r => r.data) } +export function appendFaqFromFeedback(id: string, data: any): Promise { return request.post(`/faq/${id}/append-from-feedback`, data).then(r => r.data) } +export function editFaqFromFeedback(id: string, data: any): Promise { return request.post(`/faq/${id}/edit-from-feedback`, data).then(r => r.data) } +export function getFaqFeedbackLinks(id: string): Promise { return request.get(`/faq/${id}/feedback-links`).then(r => r.data) } diff --git a/frontend/src/api/feedback.ts b/frontend/src/api/feedback.ts index ed5d8e8..7f618f9 100644 --- a/frontend/src/api/feedback.ts +++ b/frontend/src/api/feedback.ts @@ -12,3 +12,12 @@ export function getFeedbackStats(startDate?: string, endDate?: string): Promise< } export function getFeedbackByConversation(conversationId: string): Promise { return request.get(`/feedback/by-conversation/${encodeURIComponent(conversationId)}`).then(r => r.data) } export function getFeedbackBatch(messageIds: string[]): Promise { return request.get(`/feedback/batch?messageIds=${encodeURIComponent(messageIds.join(','))}`).then(r => r.data) } +export function listFeedbackConversations(params: Record): Promise { + const qs = new URLSearchParams() + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== '') qs.append(k, String(v)) + } + return request.get(`/feedback/conversations?${qs.toString()}`).then(r => r.data) +} +export function getFeedbackMessages(conversationId: string): Promise { return request.get(`/feedback/messages?conversationId=${encodeURIComponent(conversationId)}`).then(r => r.data) } +export function markFeedbackProcessed(id: string, data: any): Promise { return request.put(`/feedback/${id}/processed`, data).then(r => r.data) } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index e8bad01..73243ce 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -19,6 +19,7 @@ const routes: RouteRecordRaw[] = [ { path: '/knowledge/category', name: 'Category', component: () => import('@/views/CategoryManager.vue') }, { path: '/knowledge/search', name: 'DocSearch', component: () => import('@/views/DocSearch.vue') }, { path: '/knowledge/faq', name: 'FaqManager', component: () => import('@/views/FaqManager.vue') }, + { path: '/knowledge/feedback-ops', name: 'FeedbackOps', component: () => import('@/views/FeedbackOps.vue') }, // ==================== 会话管理 ==================== { path: '/conversation', name: 'Conversation', component: () => import('@/views/ConversationManager.vue') }, // ==================== 运营看板 ==================== @@ -66,6 +67,7 @@ router.beforeEach(async (to, _from, next) => { break case 'Category': case 'FaqManager': + case 'FeedbackOps': try { await categoryStore.loadCategories() } catch { /* */ } break case 'RoleManager': diff --git a/frontend/src/stores/navigation.ts b/frontend/src/stores/navigation.ts index dcd288b..a04b807 100644 --- a/frontend/src/stores/navigation.ts +++ b/frontend/src/stores/navigation.ts @@ -14,6 +14,7 @@ export const MENU_ITEMS = [ { id: 'category', label: '分类管理', icon: '🏷️', path: '/knowledge/category' }, { id: 'search-test', label: '搜索测试', icon: '🔍', path: '/knowledge/search' }, { id: 'faq', label: 'FAQ 管理', icon: '❓', path: '/knowledge/faq' }, + { id: 'feedback-ops', label: '反馈运营', icon: '💡', path: '/knowledge/feedback-ops', roles: ['admin', 'kb_operator'] }, ], }, { id: 'conversation', label: '会话管理', icon: '💬', path: '/conversation' }, diff --git a/frontend/src/views/ChatPanel.vue b/frontend/src/views/ChatPanel.vue index 55038d5..4039bc6 100644 --- a/frontend/src/views/ChatPanel.vue +++ b/frontend/src/views/ChatPanel.vue @@ -155,6 +155,30 @@ + + + + + + + @@ -550,28 +574,86 @@ async function copyMessage(content: string): Promise { } // ==================== 消息反馈 ==================== +// 点踩原因弹窗状态 +const feedbackReasonVisible = ref(false) +const feedbackSelectedReason = ref('') +const feedbackReasonComment = ref('') +const feedbackTarget = ref<{ msgId: string; msgIndex: number; wasActive: boolean } | null>(null) + +const REASON_OPTIONS = [ + { key: 'inaccurate', label: '不准确' }, + { key: 'irrelevant', label: '不相关' }, + { key: 'incomplete', label: '不完整' }, + { key: 'other', label: '其他' }, +] + async function submitFeedback(msgId: string, type: 'up' | 'down'): Promise { const msgIndex = messages.value.findIndex(m => m.id === msgId && m.role === 'assistant') if (msgIndex === -1) return const msg = messages.value[msgIndex] const wasActive = msg.feedback === type const newFeedback = wasActive ? null : type - msg.feedback = newFeedback - // 取消反馈仅更新前端状态,不调用后端 API(后端不支持传 null 覆盖已有记录) - if (!newFeedback) return + if (type === 'up') { + // 点赞:直接提交/取消 + msg.feedback = newFeedback + if (!newFeedback) return + try { + await submitFeedbackApi({ + messageId: String(msgId), + conversationId: chatId.value, + feedbackType: 'THUMBS_UP', + }) + toast('感谢反馈 👍', 'success') + } catch (e) { + // 失败时回滚乐观更新 + msg.feedback = wasActive ? 'up' : null + console.error('反馈提交失败:', e) + toast(e.message || '反馈提交失败', 'error') + } + } else { + // 点踩:弹出原因选择弹窗(不再乐观标记前端状态,等提交成功后再更新) + if (wasActive) { + msg.feedback = null + return + } + feedbackTarget.value = { msgId, msgIndex, wasActive } + feedbackSelectedReason.value = '' + feedbackReasonComment.value = '' + feedbackReasonVisible.value = true + } +} +async function confirmFeedbackReason(): Promise { + const target = feedbackTarget.value + if (!target || !feedbackSelectedReason.value) return + const msg = messages.value[target.msgIndex] + const reasonCategory = feedbackSelectedReason.value + const reasonComment = feedbackReasonComment.value.trim() || undefined + // 提交时才乐观标记前端状态 + msg.feedback = 'down' try { await submitFeedbackApi({ - messageId: String(msgId), + messageId: String(target.msgId), conversationId: chatId.value, - feedbackType: newFeedback === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN', + feedbackType: 'THUMBS_DOWN', + reasonCategory, + reasonComment, }) - toast(newFeedback === 'up' ? '感谢反馈 👍' : '感谢反馈,我们会持续改进', 'success') + toast('感谢反馈,我们会持续改进', 'success') } catch (e) { + // 失败时回滚 + msg.feedback = null console.error('反馈提交失败:', e) toast(e.message || '反馈提交失败', 'error') } + feedbackReasonVisible.value = false + feedbackTarget.value = null +} + +function cancelFeedbackReason(): void { + feedbackReasonVisible.value = false + feedbackTarget.value = null } // ==================== 初始化 ==================== @@ -708,4 +790,8 @@ onBeforeUnmount(() => { .chat-header { flex-direction: column; } .chat-actions { flex-wrap: wrap; } } + +/* ==================== 点踩原因弹窗 ==================== */ +.feedback-reason-options { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; } +.dialog-footer { margin-top: 12px; display: flex; justify-content: flex-end; gap: 8px; } diff --git a/frontend/src/views/ConversationManager.vue b/frontend/src/views/ConversationManager.vue index 81f9e75..6aa9128 100644 --- a/frontend/src/views/ConversationManager.vue +++ b/frontend/src/views/ConversationManager.vue @@ -37,18 +37,50 @@
{{ msg.messageType==='USER'?'用户':msg.messageType==='ASSISTANT'?'AI助手':'系统' }} {{ formatDate(msg.createTime) }} + + + {{ msg.feedback.feedbackType === 'THUMBS_UP' ? '👍 有帮助' : '👎 没帮助' }} + + {{ reasonLabel(msg.feedback.reasonCategory) }}
{{ msg.content }}
+ +
+ 📝 保存为新 FAQ + ➕ 补充到已有 FAQ + ✏️ 修正答案 +
+ + + + + + + + + + + + + + + - + diff --git a/frontend/src/views/FaqManager.vue b/frontend/src/views/FaqManager.vue index ba10d12..4160276 100644 --- a/frontend/src/views/FaqManager.vue +++ b/frontend/src/views/FaqManager.vue @@ -88,13 +88,19 @@ const form = ref({ question: '', answer: '', similarQuestionsText: '', categoryI const importJson = ref('') const flatCategories = ref([]); const categoryMap = ref>({}) +function sourceLabel(s: string): string { + const map: Record = { manual: '手动', import: '导入', feedback_positive: '👍反馈', feedback_negative: '👎反馈' } + return map[s] || s || '-' +} + const columns = [ { colKey: 'question', title: '问题', width: 200, ellipsis: true }, { colKey: 'answer', title: '答案(摘要)', width: 200, ellipsis: true }, { colKey: 'categoryName', title: '分类', width: 100, cell: (_h:any,{row}:any)=>categoryMap.value[row.categoryId]||'-' }, - { colKey: 'priority', title: '优先级', width: 70 }, - { colKey: 'hitCount', title: '命中', width: 60 }, - { colKey: 'status', title: '状态', width: 90 }, + { colKey: 'source', title: '来源', width: 70, cell: (_h:any,{row}:any)=>sourceLabel(row.source) }, + { colKey: 'priority', title: '优先级', width: 60 }, + { colKey: 'hitCount', title: '命中', width: 55 }, + { colKey: 'status', title: '状态', width: 75 }, { colKey: 'op', title: '操作', width: 120 }, ] diff --git a/frontend/src/views/FeedbackOps.vue b/frontend/src/views/FeedbackOps.vue new file mode 100644 index 0000000..b1652bb --- /dev/null +++ b/frontend/src/views/FeedbackOps.vue @@ -0,0 +1,299 @@ + + + + diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index 6e08c2a..b854113 100644 --- a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java +++ b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java @@ -120,6 +120,15 @@ public class DatabaseInitConfig { safeInit("创建消息反馈表 message_feedback", () -> { if (!checkTableExists("message_feedback")) { createMessageFeedbackTable(); + } else { + addMessageFeedbackProcessedColumns(); + } + }); + + // P0-002-EXT: FAQ 反馈维护关联 + safeInit("创建 FAQ 反馈维护关联表 faq_feedback_link", () -> { + if (!checkTableExists("faq_feedback_link")) { + createFaqFeedbackLinkTable(); } }); @@ -127,9 +136,11 @@ public class DatabaseInitConfig { safeInit("创建 FAQ 知识库表 knowledge_faq", () -> { if (!checkTableExists("knowledge_faq")) { createKnowledgeFaqTable(); + } else { + addFaqCategoryIdColumn(); + addKnowledgeFaqFeedbackColumns(); } }); - safeInit("迁移 knowledge_faq.category_id 列", this::addFaqCategoryIdColumn); safeInit("创建 FAQ 向量索引表 faq_embedding", () -> { if (!checkTableExists("faq_embedding")) { createFaqEmbeddingTable(); @@ -244,7 +255,7 @@ public class DatabaseInitConfig { "customer_service_role", "customer_service_role_category", "customer_account", "conversation_session", "ai_model_config", "sensitive_word", "content_audit_log", "message_feedback", - "knowledge_faq", "faq_embedding", + "knowledge_faq", "faq_embedding", "faq_feedback_link", "sys_user", "sys_role", "sys_permission", "sys_user_role", "rag_hit_log", "dashboard_snapshot", "api_key", "webhook_config", @@ -269,9 +280,9 @@ public class DatabaseInitConfig { private boolean checkTableExists(String tableName) { try { - String sql = "SELECT 1 FROM " + tableName + " LIMIT 1"; - jdbcTemplate.queryForObject(sql, Integer.class); - return true; + String sql = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ?"; + Integer count = jdbcTemplate.queryForObject(sql, Integer.class, tableName.toLowerCase()); + return count != null && count > 0; } catch (Exception e) { return false; } @@ -692,6 +703,9 @@ public class DatabaseInitConfig { feedback_type VARCHAR(16) NOT NULL, reason_category VARCHAR(64), reason_comment TEXT, + processed BOOLEAN DEFAULT FALSE NOT NULL, + processed_by BIGINT, + processed_time TIMESTAMP, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL @@ -702,29 +716,85 @@ public class DatabaseInitConfig { jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_feedback_conversation ON message_feedback (conversation_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_feedback_type ON message_feedback (feedback_type) WHERE is_delete = false"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_feedback_created ON message_feedback (create_time DESC)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_feedback_type_reason ON message_feedback (feedback_type, reason_category, processed, create_time)"); } - // ==================== P0-003: FAQ 知识库 ==================== + /** + * 为已存在的 message_feedback 表添加处理状态字段(反馈运营看板需要) + */ + private void addMessageFeedbackProcessedColumns() { + try { + String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'message_feedback' AND column_name = 'processed'"; + Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); + if (count != null && count == 0) { + log.info("添加 message_feedback.processed 列"); + jdbcTemplate.execute("ALTER TABLE message_feedback ADD COLUMN IF NOT EXISTS processed BOOLEAN DEFAULT FALSE NOT NULL"); + } - private void createKnowledgeFaqTable() { + checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'message_feedback' AND column_name = 'processed_by'"; + count = jdbcTemplate.queryForObject(checkSql, Integer.class); + if (count != null && count == 0) { + log.info("添加 message_feedback.processed_by 列"); + jdbcTemplate.execute("ALTER TABLE message_feedback ADD COLUMN IF NOT EXISTS processed_by BIGINT"); + } + + checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'message_feedback' AND column_name = 'processed_time'"; + count = jdbcTemplate.queryForObject(checkSql, Integer.class); + if (count != null && count == 0) { + log.info("添加 message_feedback.processed_time 列"); + jdbcTemplate.execute("ALTER TABLE message_feedback ADD COLUMN IF NOT EXISTS processed_time TIMESTAMP"); + } + + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_feedback_type_reason ON message_feedback (feedback_type, reason_category, processed, create_time)"); + } catch (Exception e) { + log.error("添加 message_feedback 处理状态列失败", e); + } + } + + // ==================== P0-002-EXT: FAQ 反馈维护关联 ==================== + + private void createFaqFeedbackLinkTable() { String sql = """ - CREATE TABLE IF NOT EXISTS knowledge_faq ( + CREATE TABLE IF NOT EXISTS faq_feedback_link ( id BIGSERIAL PRIMARY KEY, - question TEXT NOT NULL, - answer TEXT NOT NULL, - similar_questions TEXT DEFAULT '[]' NOT NULL, - category VARCHAR(128), - category_id BIGINT, - status VARCHAR(20) NOT NULL DEFAULT 'ENABLED', - priority INTEGER NOT NULL DEFAULT 0, - hit_count BIGINT NOT NULL DEFAULT 0, - source VARCHAR(64) DEFAULT 'manual', + feedback_id BIGINT NOT NULL, + faq_id BIGINT, + action_type VARCHAR(32) NOT NULL, + original_question TEXT, + original_answer TEXT, + operator_id BIGINT, + remark TEXT, 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_faq_feedback_link_faq ON faq_feedback_link (faq_id)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_feedback_link_feedback ON faq_feedback_link (feedback_id)"); + } + + // ==================== P0-003: FAQ 知识库 ==================== + + private void createKnowledgeFaqTable() { + String sql = """ + CREATE TABLE IF NOT EXISTS knowledge_faq ( + id BIGSERIAL PRIMARY KEY, + question TEXT NOT NULL, + answer TEXT NOT NULL, + similar_questions TEXT DEFAULT '[]' NOT NULL, + category VARCHAR(128), + category_id BIGINT, + status VARCHAR(20) NOT NULL DEFAULT 'ENABLED', + priority INTEGER NOT NULL DEFAULT 0, + hit_count BIGINT NOT NULL DEFAULT 0, + source VARCHAR(64) DEFAULT 'manual', + created_from_feedback_id BIGINT, + 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_faq_status ON knowledge_faq (status) WHERE is_delete = false"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_category ON knowledge_faq (category)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_category_id ON knowledge_faq (category_id)"); @@ -762,6 +832,22 @@ public class DatabaseInitConfig { } } + /** + * 为已存在的 knowledge_faq 表添加反馈来源追溯字段 + */ + private void addKnowledgeFaqFeedbackColumns() { + try { + String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'knowledge_faq' AND column_name = 'created_from_feedback_id'"; + Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); + if (count != null && count == 0) { + log.info("添加 knowledge_faq.created_from_feedback_id 列"); + jdbcTemplate.execute("ALTER TABLE knowledge_faq ADD COLUMN IF NOT EXISTS created_from_feedback_id BIGINT"); + } + } catch (Exception e) { + log.error("添加 knowledge_faq.created_from_feedback_id 列失败", e); + } + } + // ==================== P0-001: 混合检索 - 全文检索 ==================== /** @@ -1291,10 +1377,25 @@ public class DatabaseInitConfig { executeComment("COLUMN message_feedback.feedback_type", "反馈类型: THUMBS_UP(有帮助) / THUMBS_DOWN(没帮助)"); executeComment("COLUMN message_feedback.reason_category", "点踩原因分类: inaccurate / irrelevant / incomplete / other"); executeComment("COLUMN message_feedback.reason_comment", "自由文本补充说明"); + executeComment("COLUMN message_feedback.processed", "是否已被运营人员处理"); + executeComment("COLUMN message_feedback.processed_by", "处理人 ID"); + executeComment("COLUMN message_feedback.processed_time", "处理时间"); executeComment("COLUMN message_feedback.create_time", "创建时间"); executeComment("COLUMN message_feedback.update_time", "更新时间(重复提交时覆盖)"); executeComment("COLUMN message_feedback.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); + // ===== faq_feedback_link ===== + executeComment("TABLE faq_feedback_link", "FAQ 与反馈维护关联表(记录反馈如何转化为 FAQ 维护动作)"); + executeComment("COLUMN faq_feedback_link.id", "主键(雪花算法生成)"); + executeComment("COLUMN faq_feedback_link.feedback_id", "关联反馈 ID(对应 message_feedback.id)"); + executeComment("COLUMN faq_feedback_link.faq_id", "关联 FAQ ID(对应 knowledge_faq.id,创建前可为空)"); + executeComment("COLUMN faq_feedback_link.action_type", "维护动作类型: create / append_similar / edit_answer / mark_resolved"); + executeComment("COLUMN faq_feedback_link.original_question", "用户原始问题快照"); + executeComment("COLUMN faq_feedback_link.original_answer", "AI 原回复快照"); + executeComment("COLUMN faq_feedback_link.operator_id", "运营人员 ID"); + executeComment("COLUMN faq_feedback_link.remark", "备注说明"); + executeComment("COLUMN faq_feedback_link.create_time", "创建时间"); + // ===== knowledge_faq ===== executeComment("TABLE knowledge_faq", "FAQ 知识库表(用于意图路由后的精准问答匹配)"); executeComment("COLUMN knowledge_faq.id", "主键(雪花算法生成)"); @@ -1306,7 +1407,8 @@ public class DatabaseInitConfig { executeComment("COLUMN knowledge_faq.status", "状态: ENABLED(启用) / DISABLED(禁用)"); executeComment("COLUMN knowledge_faq.priority", "优先级(数值越大越优先匹配)"); executeComment("COLUMN knowledge_faq.hit_count", "命中次数统计"); - executeComment("COLUMN knowledge_faq.source", "来源: manual(手动录入) / import(批量导入)"); + executeComment("COLUMN knowledge_faq.source", "来源: manual(手动录入) / import(批量导入) / feedback_positive(点赞反馈) / feedback_negative(点踩反馈)"); + executeComment("COLUMN knowledge_faq.created_from_feedback_id", "由哪条反馈创建(追溯用)"); executeComment("COLUMN knowledge_faq.create_time", "创建时间"); executeComment("COLUMN knowledge_faq.update_time", "更新时间"); executeComment("COLUMN knowledge_faq.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); diff --git a/src/main/java/com/wok/supportbot/controller/FaqController.java b/src/main/java/com/wok/supportbot/controller/FaqController.java index 0acba72..0944791 100644 --- a/src/main/java/com/wok/supportbot/controller/FaqController.java +++ b/src/main/java/com/wok/supportbot/controller/FaqController.java @@ -1,5 +1,6 @@ package com.wok.supportbot.controller; +import com.wok.supportbot.entity.FaqFeedbackLink; import com.wok.supportbot.entity.KnowledgeFaq; import com.wok.supportbot.service.FaqService; import lombok.extern.slf4j.Slf4j; @@ -52,6 +53,150 @@ public class FaqController { } } + /** + * 从反馈创建新 FAQ + */ + @PostMapping("/from-feedback") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> createFromFeedback(@RequestBody Map body) { + try { + Long feedbackId = body.get("feedbackId") != null ? Long.valueOf(body.get("feedbackId").toString()) : null; + String question = body.get("question") != null ? body.get("question").toString() : null; + String answer = body.get("answer") != null ? body.get("answer").toString() : null; + Long categoryId = body.get("categoryId") != null ? Long.valueOf(body.get("categoryId").toString()) : null; + Integer priority = body.get("priority") != null ? Integer.valueOf(body.get("priority").toString()) : 0; + String source = body.get("source") != null ? body.get("source").toString() : "feedback"; + String originalQuestion = body.get("originalQuestion") != null ? body.get("originalQuestion").toString() : question; + String originalAnswer = body.get("originalAnswer") != null ? body.get("originalAnswer").toString() : answer; + Long operatorId = body.get("operatorId") != null ? Long.valueOf(body.get("operatorId").toString()) : null; + String remark = body.get("remark") != null ? body.get("remark").toString() : null; + + if (feedbackId == null) { + return ResponseEntity.status(400).body(Map.of("success", false, "message", "feedbackId 不能为空")); + } + if (question == null || question.isBlank()) { + return ResponseEntity.status(400).body(Map.of("success", false, "message", "问题内容不能为空")); + } + if (answer == null || answer.isBlank()) { + return ResponseEntity.status(400).body(Map.of("success", false, "message", "答案内容不能为空")); + } + + KnowledgeFaq faq = new KnowledgeFaq(); + faq.setQuestion(question.trim()); + faq.setAnswer(answer.trim()); + faq.setCategoryId(categoryId); + faq.setPriority(priority); + faq.setSource(source); + faq.setCreatedFromFeedbackId(feedbackId); + faq.setStatus("ENABLED"); + + KnowledgeFaq created = faqService.createFromFeedback(faq, feedbackId, originalQuestion, originalAnswer, operatorId, remark); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "从反馈创建 FAQ 成功", + "data", created + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(400).body(Map.of("success", false, "message", e.getMessage())); + } catch (Exception e) { + log.error("从反馈创建 FAQ 失败", e); + return ResponseEntity.status(500).body(Map.of("success", false, "message", "创建失败:" + e.getMessage())); + } + } + + /** + * 将用户真实问题追加到现有 FAQ 的 similarQuestions + */ + @PostMapping("/{id}/append-from-feedback") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> appendFromFeedback( + @PathVariable Long id, + @RequestBody Map body) { + try { + Long feedbackId = body.get("feedbackId") != null ? Long.valueOf(body.get("feedbackId").toString()) : null; + String similarQuestion = body.get("similarQuestion") != null ? body.get("similarQuestion").toString() : null; + String originalAnswer = body.get("originalAnswer") != null ? body.get("originalAnswer").toString() : null; + Long operatorId = body.get("operatorId") != null ? Long.valueOf(body.get("operatorId").toString()) : null; + + if (feedbackId == null) { + return ResponseEntity.status(400).body(Map.of("success", false, "message", "feedbackId 不能为空")); + } + if (similarQuestion == null || similarQuestion.isBlank()) { + return ResponseEntity.status(400).body(Map.of("success", false, "message", "相似问题不能为空")); + } + String remark = body.get("remark") != null ? body.get("remark").toString() : null; + + KnowledgeFaq updated = faqService.appendSimilarFromFeedback( + id, feedbackId, similarQuestion, originalAnswer, operatorId, remark); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "追加相似问题成功", + "data", updated + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(400).body(Map.of("success", false, "message", e.getMessage())); + } catch (Exception e) { + log.error("追加相似问题失败: id={}", id, e); + return ResponseEntity.status(500).body(Map.of("success", false, "message", "追加失败:" + e.getMessage())); + } + } + + /** + * 基于反馈修改 FAQ 答案 + */ + @PostMapping("/{id}/edit-from-feedback") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> editFromFeedback( + @PathVariable Long id, + @RequestBody Map body) { + try { + Long feedbackId = body.get("feedbackId") != null ? Long.valueOf(body.get("feedbackId").toString()) : null; + String answer = body.get("answer") != null ? body.get("answer").toString() : null; + String originalQuestion = body.get("originalQuestion") != null ? body.get("originalQuestion").toString() : null; + Long operatorId = body.get("operatorId") != null ? Long.valueOf(body.get("operatorId").toString()) : null; + String remark = body.get("remark") != null ? body.get("remark").toString() : null; + + if (feedbackId == null) { + return ResponseEntity.status(400).body(Map.of("success", false, "message", "feedbackId 不能为空")); + } + if (answer == null || answer.isBlank()) { + return ResponseEntity.status(400).body(Map.of("success", false, "message", "答案内容不能为空")); + } + + KnowledgeFaq updated = faqService.editAnswerFromFeedback( + id, feedbackId, answer, originalQuestion, operatorId, remark); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "修改答案成功", + "data", updated + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(400).body(Map.of("success", false, "message", e.getMessage())); + } catch (Exception e) { + log.error("基于反馈修改答案失败: id={}", id, e); + return ResponseEntity.status(500).body(Map.of("success", false, "message", "修改失败:" + e.getMessage())); + } + } + + /** + * 查询 FAQ 关联的反馈维护记录 + */ + @GetMapping("/{id}/feedback-links") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> getFeedbackLinks(@PathVariable Long id) { + try { + List links = faqService.getFeedbackLinksByFaqId(id); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "查询成功", + "data", links + )); + } catch (Exception e) { + log.error("查询 FAQ 反馈关联失败: id={}", id, e); + return ResponseEntity.status(500).body(Map.of("success", false, "message", "查询失败:" + e.getMessage())); + } + } + // ==================== 新增 ==================== /** diff --git a/src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java b/src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java index f4fc871..3ce4f4e 100644 --- a/src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java +++ b/src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java @@ -67,6 +67,88 @@ public class MessageFeedbackController { } } + /** + * 按反馈条件筛选会话列表(供反馈运营看板) + */ + @GetMapping("/feedback/conversations") + @PreAuthorize("hasAnyRole('admin','kb_operator','cs_agent','viewer')") + public ResponseEntity> listConversationsByFeedback( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String feedbackType, + @RequestParam(required = false) String reasonCategory, + @RequestParam(required = false) Boolean processed, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate) { + try { + Map data = messageFeedbackService.listConversationsByFeedback( + page, size, feedbackType, reasonCategory, processed, startDate, endDate); + return ResponseEntity.ok(Map.of( + "success", true, + "data", data + )); + } catch (Exception e) { + log.error("查询反馈会话列表失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "查询失败:" + e.getMessage() + )); + } + } + + /** + * 按会话 ID 返回带反馈标记的消息列表 + */ + @GetMapping("/feedback/messages") + @PreAuthorize("hasAnyRole('admin','kb_operator','cs_agent','viewer')") + public ResponseEntity> getMessagesWithFeedback( + @RequestParam String conversationId) { + try { + List> messages = messageFeedbackService.getMessagesWithFeedback(conversationId); + return ResponseEntity.ok(Map.of( + "success", true, + "data", messages + )); + } catch (Exception e) { + log.error("查询反馈消息列表失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "查询失败:" + e.getMessage() + )); + } + } + + /** + * 标记反馈已处理 + */ + @PutMapping("/feedback/{id}/processed") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> markProcessed( + @PathVariable Long id, + @RequestBody Map body) { + try { + Long processedBy = body.get("processedBy") != null ? Long.valueOf(body.get("processedBy").toString()) : null; + String remark = body.get("remark") != null ? body.get("remark").toString() : null; + MessageFeedback feedback = messageFeedbackService.markProcessed(id, processedBy, remark); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "标记成功", + "data", feedback + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(400).body(Map.of( + "success", false, + "message", e.getMessage() + )); + } catch (Exception e) { + log.error("标记反馈已处理失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "标记失败:" + e.getMessage() + )); + } + } + /** * 获取反馈统计数据 * diff --git a/src/main/java/com/wok/supportbot/dao/FaqFeedbackLinkMapper.java b/src/main/java/com/wok/supportbot/dao/FaqFeedbackLinkMapper.java new file mode 100644 index 0000000..59b4642 --- /dev/null +++ b/src/main/java/com/wok/supportbot/dao/FaqFeedbackLinkMapper.java @@ -0,0 +1,34 @@ +package com.wok.supportbot.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wok.supportbot.entity.FaqFeedbackLink; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * FAQ 与反馈维护关联表 Mapper + */ +@Mapper +public interface FaqFeedbackLinkMapper extends BaseMapper { + + /** + * 根据 FAQ ID 查询关联的反馈维护记录 + * + * @param faqId FAQ ID + * @return 关联记录列表 + */ + @Select("SELECT * FROM faq_feedback_link WHERE faq_id = #{faqId} AND is_delete = false ORDER BY create_time DESC") + List selectByFaqId(@Param("faqId") Long faqId); + + /** + * 根据反馈 ID 查询关联的维护记录 + * + * @param feedbackId 反馈 ID + * @return 关联记录列表 + */ + @Select("SELECT * FROM faq_feedback_link WHERE feedback_id = #{feedbackId} AND is_delete = false ORDER BY create_time DESC") + List selectByFeedbackId(@Param("feedbackId") Long feedbackId); +} diff --git a/src/main/java/com/wok/supportbot/entity/FaqFeedbackLink.java b/src/main/java/com/wok/supportbot/entity/FaqFeedbackLink.java new file mode 100644 index 0000000..ec4751e --- /dev/null +++ b/src/main/java/com/wok/supportbot/entity/FaqFeedbackLink.java @@ -0,0 +1,94 @@ +package com.wok.supportbot.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * FAQ 与反馈维护关联实体 + * 记录哪条反馈通过何种动作维护到了哪条 FAQ + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@TableName("faq_feedback_link") +public class FaqFeedbackLink implements Serializable { + + @Serial + @TableField(exist = false) + private static final long serialVersionUID = 1L; + + /** + * 主键 ID(雪花算法) + */ + @TableId(value = "id", type = IdType.ASSIGN_ID) + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** + * 关联反馈 ID + */ + @TableField("feedback_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long feedbackId; + + /** + * 关联 FAQ ID(创建前可能为空) + */ + @TableField("faq_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long faqId; + + /** + * 维护动作类型: create / append_similar / edit_answer / mark_resolved + */ + @TableField("action_type") + private String actionType; + + /** + * 用户原始问题快照 + */ + @TableField("original_question") + private String originalQuestion; + + /** + * AI 原回复快照 + */ + @TableField("original_answer") + private String originalAnswer; + + /** + * 运营人员 ID + */ + @TableField("operator_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long operatorId; + + /** + * 备注 + */ + @TableField("remark") + private String remark; + + /** + * 创建时间 + */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private Date createTime; + + /** + * 是否删除 false-未删除 true-已删除 + */ + @TableField("is_delete") + @TableLogic + private boolean isDelete; +} diff --git a/src/main/java/com/wok/supportbot/entity/KnowledgeFaq.java b/src/main/java/com/wok/supportbot/entity/KnowledgeFaq.java index 91c7788..7ef9181 100644 --- a/src/main/java/com/wok/supportbot/entity/KnowledgeFaq.java +++ b/src/main/java/com/wok/supportbot/entity/KnowledgeFaq.java @@ -83,11 +83,18 @@ public class KnowledgeFaq implements Serializable { private Long hitCount; /** - * 来源: manual / import + * 来源: manual / import / feedback_positive / feedback_negative */ @TableField("source") private String source; + /** + * 由哪条反馈创建(可选,用于追溯) + */ + @TableField("created_from_feedback_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long createdFromFeedbackId; + /** * 创建时间 */ diff --git a/src/main/java/com/wok/supportbot/entity/MessageFeedback.java b/src/main/java/com/wok/supportbot/entity/MessageFeedback.java index 79020f3..852d33b 100644 --- a/src/main/java/com/wok/supportbot/entity/MessageFeedback.java +++ b/src/main/java/com/wok/supportbot/entity/MessageFeedback.java @@ -65,6 +65,25 @@ public class MessageFeedback implements Serializable { @TableField("reason_comment") private String reasonComment; + /** + * 是否已被运营人员处理 + */ + @TableField("processed") + private Boolean processed; + + /** + * 处理人 ID + */ + @TableField("processed_by") + @JsonSerialize(using = ToStringSerializer.class) + private Long processedBy; + + /** + * 处理时间 + */ + @TableField("processed_time") + private Date processedTime; + /** * 创建时间 */ diff --git a/src/main/java/com/wok/supportbot/service/FaqService.java b/src/main/java/com/wok/supportbot/service/FaqService.java index dbf5d22..08deb47 100644 --- a/src/main/java/com/wok/supportbot/service/FaqService.java +++ b/src/main/java/com/wok/supportbot/service/FaqService.java @@ -3,8 +3,11 @@ package com.wok.supportbot.service; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.wok.supportbot.dao.FaqFeedbackLinkMapper; import com.wok.supportbot.dao.KnowledgeFaqMapper; +import com.wok.supportbot.entity.FaqFeedbackLink; import com.wok.supportbot.entity.KnowledgeFaq; +import com.wok.supportbot.entity.MessageFeedback; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; @@ -30,6 +33,12 @@ public class FaqService { @Autowired private FaqMatchEngine faqMatchEngine; + @Autowired + private FaqFeedbackLinkMapper faqFeedbackLinkMapper; + + @Autowired + private MessageFeedbackService messageFeedbackService; + private final ObjectMapper objectMapper = new ObjectMapper(); // ==================== 分页查询 ==================== @@ -45,6 +54,10 @@ public class FaqService { * @return 分页结果 Map {total, records} */ public Map list(int page, int size, String keyword, Long categoryId, String status) { + if (page < 1) page = 1; + if (size < 1) size = 20; + if (size > 100) size = 100; + QueryWrapper wrapper = new QueryWrapper<>(); if (keyword != null && !keyword.isBlank()) { @@ -69,6 +82,187 @@ public class FaqService { return result; } + // ==================== 从反馈维护 FAQ ==================== + + /** + * 记录操作关联并标记反馈已处理(三个反馈维护方法的共用尾部逻辑) + */ + private void linkAndMarkProcessed(Long feedbackId, Long faqId, String actionType, + String originalQuestion, String originalAnswer, + Long operatorId, String remark) { + FaqFeedbackLink link = FaqFeedbackLink.builder() + .feedbackId(feedbackId) + .faqId(faqId) + .actionType(actionType) + .originalQuestion(originalQuestion) + .originalAnswer(originalAnswer) + .operatorId(operatorId) + .remark(remark) + .build(); + faqFeedbackLinkMapper.insert(link); + messageFeedbackService.markProcessed(feedbackId, operatorId, remark); + } + + /** 异步计算向量(多处复用) */ + private void computeEmbeddingAsync(Long faqId, String question) { + CompletableFuture.runAsync(() -> faqMatchEngine.computeAndSaveEmbedding(faqId, question)); + } + + // ==================== 从反馈维护 FAQ ==================== + + /** + * 从反馈创建新 FAQ + * + * @param faq 待创建的 FAQ 信息 + * @param feedbackId 关联反馈 ID + * @param originalQuestion 用户原始问题快照 + * @param originalAnswer AI 原回复快照 + * @param operatorId 运营人员 ID + * @param remark 备注 + * @return 创建后的 FAQ + */ + @Transactional(rollbackFor = Exception.class) + public KnowledgeFaq createFromFeedback(KnowledgeFaq faq, Long feedbackId, + String originalQuestion, String originalAnswer, + Long operatorId, String remark) { + // 校验反馈(仅验证存在性) + if (messageFeedbackService.getById(feedbackId) == null) { + throw new IllegalArgumentException("反馈不存在: feedbackId=" + feedbackId); + } + + KnowledgeFaq created = create(faq); + + // 记录关联关系 + 标记反馈已处理 + linkAndMarkProcessed(feedbackId, created.getId(), "create", originalQuestion, originalAnswer, operatorId, remark); + + return created; + } + + /** + * 将用户真实问题追加到现有 FAQ 的 similarQuestions + * + * @param faqId 现有 FAQ ID + * @param feedbackId 关联反馈 ID + * @param similarQuestion 用户真实问题 + * @param originalAnswer AI 原回复快照 + * @param operatorId 运营人员 ID + * @param remark 备注 + * @return 更新后的 FAQ + */ + @Transactional(rollbackFor = Exception.class) + public KnowledgeFaq appendSimilarFromFeedback(Long faqId, Long feedbackId, + String similarQuestion, String originalAnswer, + Long operatorId, String remark) { + KnowledgeFaq existing = faqMapper.selectById(faqId); + if (existing == null) { + throw new IllegalArgumentException("FAQ 不存在: id=" + faqId); + } + MessageFeedback feedback = messageFeedbackService.getById(feedbackId); + if (feedback == null) { + throw new IllegalArgumentException("反馈不存在: feedbackId=" + feedbackId); + } + if (similarQuestion == null || similarQuestion.isBlank()) { + throw new IllegalArgumentException("相似问题不能为空"); + } + + // 解析并去重相似问题 + List similarQuestions = parseSimilarQuestions(existing.getSimilarQuestions()); + String trimmed = similarQuestion.trim(); + boolean exists = similarQuestions.stream() + .anyMatch(q -> q.equalsIgnoreCase(trimmed)); + if (!exists) { + similarQuestions.add(trimmed); + existing.setSimilarQuestions(toJsonString(similarQuestions)); + existing.setUpdateTime(new Date()); + faqMapper.updateById(existing); + + // 问题文本变更时重新计算向量 + computeEmbeddingAsync(faqId, existing.getQuestion()); + } + + // 记录关联关系 + 标记反馈已处理 + linkAndMarkProcessed(feedbackId, faqId, "append_similar", trimmed, originalAnswer, operatorId, remark); + + return existing; + } + + /** + * 基于反馈修改 FAQ 答案 + * + * @param faqId 现有 FAQ ID + * @param feedbackId 关联反馈 ID + * @param answer 修正后的答案 + * @param originalQuestion 用户原始问题快照 + * @param operatorId 运营人员 ID + * @param remark 备注 + * @return 更新后的 FAQ + */ + @Transactional(rollbackFor = Exception.class) + public KnowledgeFaq editAnswerFromFeedback(Long faqId, Long feedbackId, + String answer, String originalQuestion, + Long operatorId, String remark) { + KnowledgeFaq existing = faqMapper.selectById(faqId); + if (existing == null) { + throw new IllegalArgumentException("FAQ 不存在: id=" + faqId); + } + MessageFeedback feedback = messageFeedbackService.getById(feedbackId); + if (feedback == null) { + throw new IllegalArgumentException("反馈不存在: feedbackId=" + feedbackId); + } + if (answer == null || answer.isBlank()) { + throw new IllegalArgumentException("答案内容不能为空"); + } + + existing.setAnswer(answer.trim()); + existing.setUpdateTime(new Date()); + faqMapper.updateById(existing); + + // 答案变更也重新计算向量(语义可能变化) + computeEmbeddingAsync(faqId, existing.getQuestion()); + + // 记录关联关系 + 标记反馈已处理 + linkAndMarkProcessed(feedbackId, faqId, "edit_answer", originalQuestion, answer, operatorId, remark); + + return existing; + } + + /** + * 根据 FAQ ID 查询关联的反馈维护记录 + * + * @param faqId FAQ ID + * @return 关联记录列表 + */ + public List getFeedbackLinksByFaqId(Long faqId) { + return faqFeedbackLinkMapper.selectByFaqId(faqId); + } + + /** + * 解析相似问题 JSON 字符串为列表 + */ + private List parseSimilarQuestions(String json) { + if (json == null || json.isBlank()) { + return new ArrayList<>(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + log.warn("解析相似问题失败: {}", json, e); + return new ArrayList<>(); + } + } + + /** + * 将相似问题列表序列化为 JSON 字符串 + */ + private String toJsonString(List list) { + try { + return objectMapper.writeValueAsString(list); + } catch (Exception e) { + log.error("相似问题序列化失败", e); + return "[]"; + } + } + // ==================== 新增 ==================== /** @@ -99,7 +293,7 @@ public class FaqService { log.info("FAQ 已创建: id={}, question={}", faq.getId(), faq.getQuestion()); // 异步计算向量 - CompletableFuture.runAsync(() -> faqMatchEngine.computeAndSaveEmbedding(faq.getId(), faq.getQuestion())); + computeEmbeddingAsync(faq.getId(), faq.getQuestion()); return faq; } @@ -144,7 +338,7 @@ public class FaqService { log.info("FAQ 已更新: id={}", id); // 问题文本变更时重新计算向量 - CompletableFuture.runAsync(() -> faqMatchEngine.computeAndSaveEmbedding(id, existing.getQuestion())); + computeEmbeddingAsync(id, existing.getQuestion()); return existing; } diff --git a/src/main/java/com/wok/supportbot/service/MessageFeedbackService.java b/src/main/java/com/wok/supportbot/service/MessageFeedbackService.java index 62502c5..27ad250 100644 --- a/src/main/java/com/wok/supportbot/service/MessageFeedbackService.java +++ b/src/main/java/com/wok/supportbot/service/MessageFeedbackService.java @@ -27,6 +27,179 @@ public class MessageFeedbackService { @Autowired(required = false) private WebhookService webhookService; + /** + * 按反馈条件筛选会话列表(供反馈运营看板) + * + * @param page 页码(从1开始) + * @param size 每页条数 + * @param feedbackType 反馈类型(可选) + * @param reasonCategory 点踩原因分类(可选) + * @param processed 是否已处理(可选) + * @param startDate 开始日期(yyyy-MM-dd,可选) + * @param endDate 结束日期(yyyy-MM-dd,可选) + * @return 分页后的会话摘要列表 + */ + public Map listConversationsByFeedback(int page, int size, + String feedbackType, String reasonCategory, + Boolean processed, String startDate, String endDate) { + StringBuilder where = new StringBuilder("WHERE mf.is_delete = false "); + List params = new ArrayList<>(); + + if (feedbackType != null && !feedbackType.isBlank()) { + where.append("AND mf.feedback_type = ? "); + params.add(feedbackType.toUpperCase()); + } + if (reasonCategory != null && !reasonCategory.isBlank()) { + where.append("AND mf.reason_category = ? "); + params.add(reasonCategory); + } + if (processed != null) { + where.append("AND mf.processed = ? "); + params.add(processed); + } + if (startDate != null && !startDate.isBlank()) { + where.append("AND mf.create_time >= ?::timestamp "); + params.add(startDate + " 00:00:00"); + } + if (endDate != null && !endDate.isBlank()) { + where.append("AND mf.create_time <= ?::timestamp "); + params.add(endDate + " 23:59:59"); + } + + // 统计符合条件的会话总数 + String countSql = "SELECT COUNT(DISTINCT mf.conversation_id) FROM message_feedback mf " + where; + Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray()); + + // 查询会话摘要:按最后反馈时间倒序,附带最后一条反馈信息 + String querySql = """ + SELECT DISTINCT ON (mf.conversation_id) + mf.id::TEXT AS feedback_id, + mf.conversation_id AS conversation_id, + mf.feedback_type AS feedback_type, + mf.reason_category AS reason_category, + mf.reason_comment AS reason_comment, + mf.processed AS processed, + mf.create_time AS feedback_time, + cm_user.content AS user_question, + cm_assistant.content AS assistant_answer + FROM message_feedback mf + LEFT JOIN chat_message cm_user ON cm_user.conversation_id = mf.conversation_id + AND cm_user.message_type = 'USER' + AND cm_user.is_delete = false + AND cm_user.id = ( + SELECT MAX(id) FROM chat_message + WHERE conversation_id = mf.conversation_id + AND message_type = 'USER' + AND is_delete = false + ) + LEFT JOIN chat_message cm_assistant ON cm_assistant.conversation_id = mf.conversation_id + AND cm_assistant.message_type = 'ASSISTANT' + AND cm_assistant.is_delete = false + AND cm_assistant.metadata ->> 'msgId' = mf.message_id + """ + where + "\n" + """ + ORDER BY mf.conversation_id, mf.create_time DESC + LIMIT ? OFFSET ? + """; + params.add(size); + params.add((page - 1) * size); + List> rows = jdbcTemplate.queryForList(querySql, params.toArray()); + + Map result = new LinkedHashMap<>(); + result.put("total", total != null ? total : 0L); + result.put("records", rows); + return result; + } + + /** + * 按会话 ID 返回带反馈标记的消息列表 + * + * @param conversationId 会话ID + * @return 消息列表(包含 feedback 对象) + */ + public List> getMessagesWithFeedback(String conversationId) { + // 先查询该会话下所有反馈 + List feedbacks = getByConversationId(conversationId); + Map feedbackMap = new HashMap<>(); + for (MessageFeedback feedback : feedbacks) { + feedbackMap.put(feedback.getMessageId(), feedback); + } + + // 查询会话消息(按创建时间升序) + String sql = """ + SELECT id, conversation_id, message_type, content, metadata, + create_time, update_time + FROM chat_message + WHERE conversation_id = ? AND is_delete = false + ORDER BY create_time ASC + """; + List> messages = jdbcTemplate.queryForList(sql, conversationId); + + // 为 AI 消息附加反馈信息 + List> result = new ArrayList<>(); + for (Map message : messages) { + Map item = new LinkedHashMap<>(message); + String msgId = null; + if (message.get("metadata") != null) { + String metadataStr = String.valueOf(message.get("metadata")); + // 简单从 JSON 字符串中提取 msgId + int idx = metadataStr.indexOf("\"msgId\""); + if (idx >= 0) { + int colonIdx = metadataStr.indexOf(":", idx); + int quoteStart = metadataStr.indexOf("\"", colonIdx + 1); + int quoteEnd = metadataStr.indexOf("\"", quoteStart + 1); + if (quoteStart >= 0 && quoteEnd > quoteStart) { + msgId = metadataStr.substring(quoteStart + 1, quoteEnd); + } + } + } + if (msgId != null && feedbackMap.containsKey(msgId)) { + MessageFeedback feedback = feedbackMap.get(msgId); + Map fbMap = new LinkedHashMap<>(); + fbMap.put("id", String.valueOf(feedback.getId())); + fbMap.put("feedbackType", feedback.getFeedbackType()); + fbMap.put("reasonCategory", feedback.getReasonCategory()); + fbMap.put("reasonComment", feedback.getReasonComment()); + fbMap.put("processed", feedback.getProcessed()); + fbMap.put("processedBy", feedback.getProcessedBy() != null ? String.valueOf(feedback.getProcessedBy()) : null); + fbMap.put("processedTime", feedback.getProcessedTime()); + fbMap.put("createTime", feedback.getCreateTime()); + item.put("feedback", fbMap); + } else { + item.put("feedback", null); + } + result.add(item); + } + return result; + } + + /** + * 标记反馈已处理 + * + * @param id 反馈ID + * @param processedBy 处理人ID + * @param remark 备注 + * @return 更新后的反馈实体 + */ + public MessageFeedback markProcessed(Long id, Long processedBy, String remark) { + MessageFeedback feedback = messageFeedbackMapper.selectById(id); + if (feedback == null) { + throw new IllegalArgumentException("反馈不存在: id=" + id); + } + feedback.setProcessed(true); + feedback.setProcessedBy(processedBy); + feedback.setProcessedTime(new Date()); + if (remark != null && !remark.isBlank()) { + String original = feedback.getReasonComment(); + feedback.setReasonComment( + (original != null && !original.isBlank() ? original + "\n--- 运营备注 ---\n" : "") + remark + ); + } + feedback.setUpdateTime(new Date()); + messageFeedbackMapper.updateById(feedback); + log.info("反馈已标记为已处理: id={}, processedBy={}", id, processedBy); + return feedback; + } + /** * 提交/修改反馈(upsert 语义) * 按 messageId 查询,存在则更新(覆盖上次),不存在则插入 @@ -54,6 +227,7 @@ public class MessageFeedbackService { // 新增反馈 feedback.setCreateTime(new Date()); feedback.setUpdateTime(new Date()); + feedback.setProcessed(false); messageFeedbackMapper.insert(feedback); log.info("新增反馈: messageId={}, type={}", feedback.getMessageId(), feedback.getFeedbackType()); saved = feedback; @@ -74,6 +248,16 @@ public class MessageFeedbackService { return saved; } + /** + * 按反馈 ID 查询反馈(包装方法,供 FaqService 调用) + * + * @param id 反馈 ID + * @return 反馈实体 + */ + public MessageFeedback getById(Long id) { + return messageFeedbackMapper.selectById(id); + } + /** * 按会话ID查询所有反馈 * diff --git a/src/main/resources/init-database.sql b/src/main/resources/init-database.sql index 8549303..3f3415f 100644 --- a/src/main/resources/init-database.sql +++ b/src/main/resources/init-database.sql @@ -462,6 +462,9 @@ CREATE TABLE IF NOT EXISTS message_feedback ( feedback_type VARCHAR(16) NOT NULL, reason_category VARCHAR(64), reason_comment TEXT, + processed BOOLEAN NOT NULL DEFAULT FALSE, + processed_by BIGINT, + processed_time TIMESTAMP, create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, is_delete BOOLEAN NOT NULL DEFAULT FALSE @@ -472,6 +475,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_message_feedback_message ON message_feedbac CREATE INDEX IF NOT EXISTS idx_feedback_conversation ON message_feedback (conversation_id); CREATE INDEX IF NOT EXISTS idx_feedback_type ON message_feedback (feedback_type) WHERE is_delete = FALSE; CREATE INDEX IF NOT EXISTS idx_feedback_created ON message_feedback (create_time DESC); +CREATE INDEX IF NOT EXISTS idx_feedback_type_reason ON message_feedback (feedback_type, reason_category, processed, create_time); COMMENT ON TABLE message_feedback IS '消息反馈表(用户对 AI 回复的点赞/点踩反馈)'; COMMENT ON COLUMN message_feedback.id IS '主键(雪花算法生成)'; @@ -480,10 +484,43 @@ COMMENT ON COLUMN message_feedback.conversation_id IS '会话ID(关联 chat_m COMMENT ON COLUMN message_feedback.feedback_type IS '反馈类型: THUMBS_UP(有帮助) / THUMBS_DOWN(没帮助)'; COMMENT ON COLUMN message_feedback.reason_category IS '点踩原因分类: inaccurate / irrelevant / incomplete / other(仅 THUMBS_DOWN 时可选)'; COMMENT ON COLUMN message_feedback.reason_comment IS '自由文本补充说明(可选)'; +COMMENT ON COLUMN message_feedback.processed IS '是否已被运营人员处理'; +COMMENT ON COLUMN message_feedback.processed_by IS '处理人 ID'; +COMMENT ON COLUMN message_feedback.processed_time IS '处理时间'; COMMENT ON COLUMN message_feedback.create_time IS '创建时间'; COMMENT ON COLUMN message_feedback.update_time IS '更新时间(重复提交时覆盖)'; COMMENT ON COLUMN message_feedback.is_delete IS '逻辑删除: FALSE=正常 TRUE=已删除'; +-- ============================================================ +-- 表 12-EXT: faq_feedback_link — FAQ 与反馈维护关联表 +-- ============================================================ +CREATE TABLE IF NOT EXISTS faq_feedback_link ( + id BIGSERIAL PRIMARY KEY, + feedback_id BIGINT NOT NULL, + faq_id BIGINT, + action_type VARCHAR(32) NOT NULL, + original_question TEXT, + original_answer TEXT, + operator_id BIGINT, + remark TEXT, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_delete BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_faq_feedback_link_faq ON faq_feedback_link (faq_id); +CREATE INDEX IF NOT EXISTS idx_faq_feedback_link_feedback ON faq_feedback_link (feedback_id); + +COMMENT ON TABLE faq_feedback_link IS 'FAQ 与反馈维护关联表(记录反馈如何转化为 FAQ 维护动作)'; +COMMENT ON COLUMN faq_feedback_link.id IS '主键(雪花算法生成)'; +COMMENT ON COLUMN faq_feedback_link.feedback_id IS '关联反馈 ID(对应 message_feedback.id)'; +COMMENT ON COLUMN faq_feedback_link.faq_id IS '关联 FAQ ID(对应 knowledge_faq.id,创建前可为空)'; +COMMENT ON COLUMN faq_feedback_link.action_type IS '维护动作类型: create / append_similar / edit_answer / mark_resolved'; +COMMENT ON COLUMN faq_feedback_link.original_question IS '用户原始问题快照'; +COMMENT ON COLUMN faq_feedback_link.original_answer IS 'AI 原回复快照'; +COMMENT ON COLUMN faq_feedback_link.operator_id IS '运营人员 ID'; +COMMENT ON COLUMN faq_feedback_link.remark IS '备注说明'; +COMMENT ON COLUMN faq_feedback_link.create_time IS '创建时间'; + -- ============================================================ -- 表 13: knowledge_faq — FAQ 知识库表(P0-003 意图识别 + FAQ 精准匹配) -- ============================================================ @@ -497,10 +534,11 @@ CREATE TABLE IF NOT EXISTS knowledge_faq ( status VARCHAR(20) NOT NULL DEFAULT 'ENABLED', priority INTEGER NOT NULL DEFAULT 0, hit_count BIGINT NOT NULL DEFAULT 0, - source VARCHAR(64) DEFAULT 'manual', - create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - is_delete BOOLEAN NOT NULL DEFAULT FALSE + source VARCHAR(64) DEFAULT 'manual', + created_from_feedback_id BIGINT, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_delete BOOLEAN NOT NULL DEFAULT FALSE ); CREATE INDEX IF NOT EXISTS idx_faq_status ON knowledge_faq (status) WHERE is_delete = FALSE; @@ -517,7 +555,8 @@ COMMENT ON COLUMN knowledge_faq.category IS 'FAQ 分类'; COMMENT ON COLUMN knowledge_faq.status IS '状态: ENABLED(启用) / DISABLED(禁用)'; COMMENT ON COLUMN knowledge_faq.priority IS '优先级(数值越大越优先匹配,默认 0)'; COMMENT ON COLUMN knowledge_faq.hit_count IS '命中次数统计(用于分析高频问题)'; -COMMENT ON COLUMN knowledge_faq.source IS '来源: manual(手动录入) / import(批量导入)'; +COMMENT ON COLUMN knowledge_faq.source IS '来源: manual(手动录入) / import(批量导入) / feedback_positive(点赞反馈) / feedback_negative(点踩反馈)'; +COMMENT ON COLUMN knowledge_faq.created_from_feedback_id IS '由哪条反馈创建(追溯用)'; COMMENT ON COLUMN knowledge_faq.create_time IS '创建时间'; COMMENT ON COLUMN knowledge_faq.update_time IS '更新时间'; COMMENT ON COLUMN knowledge_faq.is_delete IS '逻辑删除: FALSE=正常 TRUE=已删除';