Browse Source

新增反馈运营看板

TDesign-Vue-Next-1.20.6
wanghanlin 2 weeks ago
parent
commit
1ed6c8fb43
  1. 4
      frontend/src/api/faq.ts
  2. 9
      frontend/src/api/feedback.ts
  3. 2
      frontend/src/router/index.ts
  4. 1
      frontend/src/stores/navigation.ts
  5. 96
      frontend/src/views/ChatPanel.vue
  6. 187
      frontend/src/views/ConversationManager.vue
  7. 12
      frontend/src/views/FaqManager.vue
  8. 299
      frontend/src/views/FeedbackOps.vue
  9. 114
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  10. 145
      src/main/java/com/wok/supportbot/controller/FaqController.java
  11. 82
      src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java
  12. 34
      src/main/java/com/wok/supportbot/dao/FaqFeedbackLinkMapper.java
  13. 94
      src/main/java/com/wok/supportbot/entity/FaqFeedbackLink.java
  14. 9
      src/main/java/com/wok/supportbot/entity/KnowledgeFaq.java
  15. 19
      src/main/java/com/wok/supportbot/entity/MessageFeedback.java
  16. 198
      src/main/java/com/wok/supportbot/service/FaqService.java
  17. 184
      src/main/java/com/wok/supportbot/service/MessageFeedbackService.java
  18. 41
      src/main/resources/init-database.sql

4
frontend/src/api/faq.ts

@ -15,3 +15,7 @@ export function toggleFaqStatus(id: string, status: string): Promise<ApiResponse
export function batchImportFaqs(data: { faqs: any[] }): Promise<ApiResponse> { return request.post('/faq/batch-import', data).then(r => r.data) }
export function exportFaqs(): Promise<ApiResponse> { return request.get('/faq/export').then(r => r.data) }
export function getFaqStats(): Promise<ApiResponse> { return request.get('/faq/stats').then(r => r.data) }
export function createFaqFromFeedback(data: any): Promise<ApiResponse> { return request.post('/faq/from-feedback', data).then(r => r.data) }
export function appendFaqFromFeedback(id: string, data: any): Promise<ApiResponse> { return request.post(`/faq/${id}/append-from-feedback`, data).then(r => r.data) }
export function editFaqFromFeedback(id: string, data: any): Promise<ApiResponse> { return request.post(`/faq/${id}/edit-from-feedback`, data).then(r => r.data) }
export function getFaqFeedbackLinks(id: string): Promise<ApiResponse> { return request.get(`/faq/${id}/feedback-links`).then(r => r.data) }

9
frontend/src/api/feedback.ts

@ -12,3 +12,12 @@ export function getFeedbackStats(startDate?: string, endDate?: string): Promise<
}
export function getFeedbackByConversation(conversationId: string): Promise<ApiResponse> { return request.get(`/feedback/by-conversation/${encodeURIComponent(conversationId)}`).then(r => r.data) }
export function getFeedbackBatch(messageIds: string[]): Promise<ApiResponse> { return request.get(`/feedback/batch?messageIds=${encodeURIComponent(messageIds.join(','))}`).then(r => r.data) }
export function listFeedbackConversations(params: Record<string, any>): Promise<ApiResponse> {
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<ApiResponse> { return request.get(`/feedback/messages?conversationId=${encodeURIComponent(conversationId)}`).then(r => r.data) }
export function markFeedbackProcessed(id: string, data: any): Promise<ApiResponse> { return request.put(`/feedback/${id}/processed`, data).then(r => r.data) }

2
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':

1
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' },

96
frontend/src/views/ChatPanel.vue

@ -155,6 +155,30 @@
</div>
</footer>
</section>
<!-- 点踩原因弹窗 -->
<t-dialog v-model:visible="feedbackReasonVisible" header="请告诉我们哪里做得不好?" width="400px" :footer="false">
<div class="feedback-reason-options">
<t-button
v-for="opt in REASON_OPTIONS"
:key="opt.key"
:variant="feedbackSelectedReason === opt.key ? undefined : 'outline'"
:theme="feedbackSelectedReason === opt.key ? 'primary' : undefined"
size="small"
@click="feedbackSelectedReason = opt.key"
>{{ opt.label }}</t-button>
</div>
<t-textarea
v-model="feedbackReasonComment"
placeholder="补充说明(选填,最多200字)"
:maxlength="200"
:autosize="{ minRows: 2, maxRows: 3 }"
/>
<div class="dialog-footer">
<t-button variant="outline" size="small" @click="cancelFeedbackReason">取消</t-button>
<t-button theme="primary" size="small" :disabled="!feedbackSelectedReason" @click="confirmFeedbackReason">提交</t-button>
</div>
</t-dialog>
</div>
</template>
@ -550,28 +574,86 @@ async function copyMessage(content: string): Promise<void> {
}
// ==================== ====================
//
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<void> {
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 (type === 'up') {
// /
msg.feedback = newFeedback
if (!newFeedback) return
try {
await submitFeedbackApi({
messageId: String(msgId),
conversationId: chatId.value,
feedbackType: newFeedback === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN',
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<void> {
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(target.msgId),
conversationId: chatId.value,
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; }
</style>

187
frontend/src/views/ConversationManager.vue

@ -37,18 +37,50 @@
<div class="msg-meta">
<t-tag size="small" :theme="msg.messageType==='USER'?'primary':msg.messageType==='ASSISTANT'?'success':'default'" variant="light">{{ msg.messageType==='USER'?'用户':msg.messageType==='ASSISTANT'?'AI助手':'系统' }}</t-tag>
<span class="msg-time">{{ formatDate(msg.createTime) }}</span>
<!-- 反馈标记 -->
<t-tag v-if="msg.feedback" size="small" :theme="msg.feedback.feedbackType === 'THUMBS_UP' ? 'success' : 'danger'" variant="light">
{{ msg.feedback.feedbackType === 'THUMBS_UP' ? '👍 有帮助' : '👎 没帮助' }}
</t-tag>
<t-tag v-if="msg.feedback?.reasonCategory" size="small" theme="warning" variant="light">{{ reasonLabel(msg.feedback.reasonCategory) }}</t-tag>
</div>
<div class="msg-body">{{ msg.content }}</div>
<!-- AI 消息 FAQ 维护入口 -->
<div v-if="msg.messageType === 'ASSISTANT' && msg.content" class="msg-faq-actions">
<t-button size="small" variant="outline" @click="saveAsFaq(msg)">📝 保存为新 FAQ</t-button>
<t-button size="small" variant="outline" @click="openAppendFaq(msg)"> 补充到已有 FAQ</t-button>
<t-button size="small" variant="outline" @click="openEditFaqAnswer(msg)"> 修正答案</t-button>
</div>
</div>
</div>
</t-dialog>
<!-- FAQ 操作弹窗 -->
<t-dialog v-model:visible="faqModal.visible" :header="faqModal.title" width="600px" :footer="false">
<t-form label-align="top">
<t-form-item v-if="faqModal.mode === 'create'" label="标准问题 *"><t-input v-model="faqModal.question" placeholder="从用户问题提取标准问题" /></t-form-item>
<t-form-item v-if="faqModal.mode === 'create'" label="标准答案 *"><t-textarea v-model="faqModal.answer" :autosize="{ minRows: 3, maxRows: 6 }" placeholder="基于 AI 回复整理标准答案" /></t-form-item>
<t-form-item v-if="faqModal.mode === 'append'" label="选择目标 FAQ"><t-select v-model="faqModal.targetFaqId" :options="faqSelectOptions" placeholder="搜索并选择 FAQ" filterable /></t-form-item>
<t-form-item v-if="faqModal.mode === 'append'" label="相似问法"><t-input v-model="faqModal.similarQuestion" placeholder="用户真实问题" /></t-form-item>
<t-form-item v-if="faqModal.mode === 'edit'" label="选择目标 FAQ"><t-select v-model="faqModal.targetFaqId" :options="faqSelectOptions" placeholder="搜索并选择 FAQ" filterable /></t-form-item>
<t-form-item v-if="faqModal.mode === 'edit'" label="修正后的答案"><t-textarea v-model="faqModal.answer" :autosize="{ minRows: 3, maxRows: 6 }" /></t-form-item>
<t-form-item v-if="faqModal.mode === 'create'" label="分类"><t-select v-model="faqModal.categoryId" :options="categorySelectOptions" placeholder="不分类" clearable /></t-form-item>
<t-form-item v-if="faqModal.mode === 'create'" label="优先级"><t-input-number v-model="faqModal.priority" :min="0" style="width:100%;" /></t-form-item>
</t-form>
<div class="dialog-footer">
<t-button variant="outline" @click="faqModal.visible = false">取消</t-button>
<t-button theme="primary" @click="submitFaqAction" :disabled="!canSubmitFaq">确定</t-button>
</div>
</t-dialog>
</t-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { listConversations, deleteConversation, getConversationMessages, getConversationStats, exportConversation } from '@/api/conversation'
import { getRoleList } from '@/api/role'
import { getCategoryTree } from '@/api/category'
import { getFeedbackMessages } from '@/api/feedback'
import { listFaqs, createFaqFromFeedback, appendFaqFromFeedback, editFaqFromFeedback } from '@/api/faq'
import { toast } from '@/utils/toast'
import { formatDate } from '@/utils/format'
import { useConfirm } from '@/composables/useConfirm'
@ -61,9 +93,42 @@ const roles=ref<any[]>([]);const stats=ref<any>(null)
const msgModal=ref({visible:false,conversationId:'',roleName:'',messages:[] as any[]})
const roleOpts=ref([{label:'全部角色',value:''}])
// FAQ
const faqModal = ref<{
visible: boolean; title: string; mode: 'create' | 'append' | 'edit';
question: string; answer: string; similarQuestion: string;
targetFaqId: string; categoryId: any; priority: number;
sourceMsg: any;
}>({ visible: false, title: '', mode: 'create', question: '', answer: '', similarQuestion: '', targetFaqId: '', categoryId: null, priority: 0, sourceMsg: null })
const faqList = ref<any[]>([])
const flatCategories = ref<any[]>([])
const faqSelectOptions = computed(() => faqList.value.map((f: any) => ({ label: f.question, value: String(f.id) })))
const categorySelectOptions = computed(() => [{ label: '不分类', value: null as any }, ...flatCategories.value.map((c: any) => ({ label: c.path, value: c.id }))])
const canSubmitFaq = computed(() => {
if (faqModal.value.mode === 'create') return faqModal.value.question && faqModal.value.answer
if (faqModal.value.mode === 'append') return faqModal.value.targetFaqId && faqModal.value.similarQuestion
if (faqModal.value.mode === 'edit') return faqModal.value.targetFaqId && faqModal.value.answer
return false
})
const columns=[{colKey:'conversationId',title:'会话ID',width:160,ellipsis:true},{colKey:'accountId',title:'外部用户ID',width:100},{colKey:'roleName',title:'角色',width:80},{colKey:'messageCount',title:'消息数',width:60},{colKey:'lastMessageTime',title:'最后消息时间',width:140,cell:(_:any,{row}:any)=>formatDate(row.lastMessageTime)},{colKey:'lastMessagePreview',title:'最后消息预览',width:250,ellipsis:true},{colKey:'op',title:'操作',width:200}]
onMounted(()=>{loadFilters();load();loadStats()})
onMounted(()=>{loadFilters();load();loadStats();loadCategories()})
async function loadCategories() {
try { const r = await getCategoryTree(); if (r.success) { flatCategories.value = flattenTree(r.data) } } catch {/* */}
}
function flattenTree(tree: any[], prefix = ''): any[] {
const r: any[] = []
for (const n of (tree||[])) { const p = prefix?`${prefix}/${n.name}`:n.name; r.push({id:n.id,name:n.name,path:p}); if(n.children?.length) r.push(...flattenTree(n.children,p)) }
return r
}
function reasonLabel(c: string): string {
const map: Record<string, string> = { inaccurate: '不准确', irrelevant: '不相关', incomplete: '不完整', other: '其他' }
return map[c] || c
}
async function load(p=1){page.value=p
try{const r=await listConversations(p,pageSize.value,keyword.value||undefined,accountFilter.value||undefined,roleFilter.value||undefined);if(r.success){conversations.value=r.data||[];total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}}
@ -73,11 +138,125 @@ async function loadFilters(){try{const r=await getRoleList();if(r.success){roles
async function loadStats(){try{const r=await getConversationStats();if(r.success)stats.value=r.data}catch(e){console.error(e)}}
async function viewMessages(cid:string){try{const r=await getConversationMessages(cid);if(r.success){msgModal.value.conversationId=cid;msgModal.value.messages=r.data||[];const c=conversations.value.find(i=>i.conversationId===cid);msgModal.value.roleName=c?.roleName||'';msgModal.value.visible=true}else toast(r.message||'加载失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}}
async function viewMessages(cid:string){
try{
const r = await getConversationMessages(cid)
if (r.success) {
msgModal.value.conversationId = cid
const rawMessages = r.data || []
//
try {
const fbResp = await getFeedbackMessages(cid)
const fbMessages = fbResp.data || []
const fbById: Record<string, any> = {}
fbMessages.forEach((m: any) => { if (m.feedback) fbById[m.feedback.id] = m.feedback })
// getConversationMessages feedback
msgModal.value.messages = rawMessages.map((m: any) => {
const msgId = m.metadata?.msgId
const matched = msgId ? fbById[msgId] : null
return matched ? { ...m, feedback: matched } : m
})
} catch {
msgModal.value.messages = rawMessages
}
const c = conversations.value.find(i => i.conversationId === cid)
msgModal.value.roleName = c?.roleName || ''
msgModal.value.visible = true
} else {
toast(r.message || '加载失败', 'error')
}
} catch(e: any) { toast('加载失败:' + e.message, 'error') }
}
async function remove(cid:string){if(!await confirm('确定删除?所有消息将被逻辑删除'))return
try{const r=await deleteConversation(cid);if(r.success){toast(`已删除 ${r.deletedMessages||0} 条消息`,'success');load(page.value);loadStats()}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}}
async function downloadExport(cid:string){try{const content=await exportConversation(cid);const b=new Blob([content],{type:'text/plain;charset=utf-8'});const u=URL.createObjectURL(b);const a=document.createElement('a');a.href=u;a.download=`conversation_${cid}.txt`;document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(u);toast('导出成功','success')}catch(e:any){toast('导出失败:'+e.message,'error')}}
// ==================== FAQ ====================
async function loadFaqList() {
try { const r = await listFaqs(1, 100); if (r.success) { faqList.value = r.data?.records || r.data || [] } } catch {/* */}
}
/** 查找给定 AI 消息之前的最后一条用户消息 */
function findUserQuestion(aiMsg: any): string {
const msgs = msgModal.value.messages
const idx = msgs.findIndex((m: any) => m.id === aiMsg.id)
if (idx < 0) return ''
for (let i = idx - 1; i >= 0; i--) {
if (msgs[i].messageType === 'USER') return msgs[i].content || ''
}
return ''
}
async function saveAsFaq(msg: any) {
await loadFaqList()
faqModal.value = {
visible: true, title: '保存为新 FAQ', mode: 'create',
question: findUserQuestion(msg), answer: msg.content, similarQuestion: '',
targetFaqId: '', categoryId: null, priority: 0, sourceMsg: msg,
}
}
async function openAppendFaq(msg: any) {
await loadFaqList()
faqModal.value = {
visible: true, title: '补充到已有 FAQ', mode: 'append',
question: '', answer: '', similarQuestion: findUserQuestion(msg),
targetFaqId: '', categoryId: null, priority: 0, sourceMsg: msg,
}
}
async function openEditFaqAnswer(msg: any) {
await loadFaqList()
faqModal.value = {
visible: true, title: '修正 FAQ 答案', mode: 'edit',
question: '', answer: msg.content, similarQuestion: '',
targetFaqId: '', categoryId: null, priority: 0, sourceMsg: msg,
}
}
async function submitFaqAction() {
const m = faqModal.value
const msg = m.sourceMsg
const userQ = findUserQuestion(msg)
try {
if (m.mode === 'create') {
const feedbackId = msg.feedback?.id
const r = await createFaqFromFeedback({
feedbackId: feedbackId ? String(feedbackId) : null,
question: m.question, answer: m.answer,
categoryId: m.categoryId, priority: m.priority,
source: feedbackId ? (msg.feedback?.feedbackType === 'THUMBS_DOWN' ? 'feedback_negative' : 'feedback_positive') : 'manual',
originalQuestion: userQ, originalAnswer: msg.content,
operatorId: null, remark: null,
})
if (r.success) { toast('FAQ 创建成功', 'success'); faqModal.value.visible = false }
else toast(r.message || '创建失败', 'error')
} else if (m.mode === 'append') {
const r = await appendFaqFromFeedback(m.targetFaqId, {
feedbackId: msg.feedback?.id ? String(msg.feedback.id) : null,
similarQuestion: m.similarQuestion, originalAnswer: msg.content,
operatorId: null, remark: null,
})
if (r.success) { toast('补充成功', 'success'); faqModal.value.visible = false }
else toast(r.message || '补充失败', 'error')
} else if (m.mode === 'edit') {
const r = await editFaqFromFeedback(m.targetFaqId, {
feedbackId: msg.feedback?.id ? String(msg.feedback.id) : null,
answer: m.answer, originalQuestion: userQ,
operatorId: null, remark: null,
})
if (r.success) { toast('修正成功', 'success'); faqModal.value.visible = false }
else toast(r.message || '修正失败', 'error')
}
} catch (e: any) {
toast('操作失败:' + e.message, 'error')
}
}
</script>
<style scoped>.msg-item{padding:12px;border-radius:8px;margin-bottom:12px;}.msg-user{background:#eff6ff;}.msg-assistant{background:#f9fafb;}.msg-system{background:#fef3c7;}.msg-meta{display:flex;align-items:center;gap:8px;margin-bottom:6px;}.msg-time{font-size:11px;color:#999;}.msg-body{font-size:13px;line-height:1.6;white-space:pre-wrap;word-break:break-word;}</style>
<style scoped>.msg-item{padding:12px;border-radius:8px;margin-bottom:12px;}.msg-user{background:#eff6ff;}.msg-assistant{background:#f9fafb;}.msg-system{background:#fef3c7;}.msg-meta{display:flex;align-items:center;gap:8px;margin-bottom:6px;}.msg-time{font-size:11px;color:#999;}.msg-body{font-size:13px;line-height:1.6;white-space:pre-wrap;word-break:break-word;}
.msg-faq-actions { margin-top: 8px; display: flex; gap: 6px; }
.dialog-footer { margin-top: 16px; display: flex; justify-content: flex-end; gap: 8px; }
</style>

12
frontend/src/views/FaqManager.vue

@ -88,13 +88,19 @@ const form = ref({ question: '', answer: '', similarQuestionsText: '', categoryI
const importJson = ref('')
const flatCategories = ref<any[]>([]); const categoryMap = ref<Record<string, string>>({})
function sourceLabel(s: string): string {
const map: Record<string, string> = { 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 },
]

299
frontend/src/views/FeedbackOps.vue

@ -0,0 +1,299 @@
<template>
<t-card title="💡 反馈运营看板" :bordered="false">
<!-- 筛选栏 -->
<div class="toolbar">
<t-select v-model="filterFeedbackType" :options="feedbackTypeOptions" placeholder="全部类型" clearable size="small" style="width:140px;" />
<t-select v-model="filterReasonCategory" :options="reasonCategoryOptions" placeholder="全部原因" clearable size="small" style="width:120px;" />
<t-select v-model="filterProcessed" :options="processedOptions" placeholder="全部状态" clearable size="small" style="width:120px;" />
<t-date-picker v-model="filterDate" size="small" style="width:240px;" placeholder="选择日期范围" />
<t-button size="small" @click="loadList(1)">搜索</t-button>
<t-button variant="outline" size="small" @click="loadList(1)">刷新</t-button>
</div>
<!-- 反馈列表 -->
<t-table :data="feedbacks" :columns="columns" row-key="conversationId" :loading="loading"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange"
empty="暂无反馈数据,用户在 SDK 端对 AI 回复点赞或点踩后将在此展示。">
<template #feedbackType="{ row }">
<t-tag :theme="row.feedbackType === 'THUMBS_UP' ? 'success' : 'danger'" variant="light">
{{ row.feedbackType === 'THUMBS_UP' ? '👍 有帮助' : '👎 没帮助' }}
</t-tag>
</template>
<template #reasonCategory="{ row }">
<t-tag v-if="row.reasonCategory" theme="warning" variant="light">{{ reasonLabel(row.reasonCategory) }}</t-tag>
<span v-else>-</span>
</template>
<template #processed="{ row }">
<t-tag :theme="row.processed ? 'success' : 'default'" variant="light">{{ row.processed ? '已处理' : '待处理' }}</t-tag>
</template>
<template #userQuestion="{ row }">
<span :title="row.userQuestion" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;display:inline-block;">{{ truncate(row.userQuestion, 60) }}</span>
</template>
<template #assistantAnswer="{ row }">
<span :title="row.assistantAnswer" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;display:inline-block;">{{ truncate(row.assistantAnswer, 60) }}</span>
</template>
<template #op="{ row }">
<t-space :size="4">
<t-button size="small" variant="text" @click="viewDetail(row)">查看</t-button>
<t-button size="small" variant="text" theme="primary" @click="createFaqFromRow(row)" v-if="row.feedbackType === 'THUMBS_UP'">生成 FAQ</t-button>
<t-button size="small" variant="text" theme="warning" @click="openFaqAction(row)" v-if="row.feedbackType === 'THUMBS_DOWN'">关联 FAQ</t-button>
<t-button size="small" variant="text" v-if="!row.processed" @click="markHandled(row)">标记已处理</t-button>
</t-space>
</template>
</t-table>
<!-- 消息详情弹窗 -->
<t-dialog v-model:visible="detailModal.visible" header="会话消息详情" width="800px" :footer="false">
<div v-if="!detailModal.messages?.length"><t-empty /></div>
<div v-else class="msg-list" style="max-height:400px;overflow-y:auto;">
<div v-for="(msg, i) in detailModal.messages" :key="i" :class="['msg-item', msg.messageType === 'USER' ? 'msg-user' : msg.messageType === 'ASSISTANT' ? 'msg-assistant' : 'msg-system']">
<div class="msg-meta">
<t-tag size="small" :theme="msg.messageType === 'USER' ? 'primary' : msg.messageType === 'ASSISTANT' ? 'success' : 'default'" variant="light">
{{ msg.messageType === 'USER' ? '用户' : msg.messageType === 'ASSISTANT' ? 'AI助手' : '系统' }}
</t-tag>
<t-tag v-if="msg.feedback" size="small" :theme="msg.feedback.feedbackType === 'THUMBS_UP' ? 'success' : 'danger'" variant="light">
{{ msg.feedback.feedbackType === 'THUMBS_UP' ? '👍 有帮助' : '👎 没帮助' }}
<span v-if="msg.feedback.reasonCategory"> · {{ reasonLabel(msg.feedback.reasonCategory) }}</span>
</t-tag>
</div>
<div class="msg-body" v-html="msg.messageType === 'ASSISTANT' ? renderMarkdown(msg.content) : msg.content"></div>
</div>
</div>
</t-dialog>
<!-- FAQ 操作弹窗复用 -->
<t-dialog v-model:visible="faqModal.visible" :header="faqModal.title" width="600px" :footer="false">
<t-form label-align="top">
<t-form-item v-if="faqModal.mode === 'create'" label="标准问题 *"><t-input v-model="faqModal.question" /></t-form-item>
<t-form-item v-if="faqModal.mode === 'create'" label="标准答案 *"><t-textarea v-model="faqModal.answer" :autosize="{ minRows: 3, maxRows: 6 }" /></t-form-item>
<t-form-item v-if="faqModal.mode === 'append'" label="选择目标 FAQ"><t-select v-model="faqModal.targetFaqId" :options="faqOptions" placeholder="搜索并选择" filterable /></t-form-item>
<t-form-item v-if="faqModal.mode === 'append'" label="相似问法"><t-input v-model="faqModal.similarQuestion" /></t-form-item>
<t-form-item v-if="faqModal.mode === 'edit'" label="选择目标 FAQ"><t-select v-model="faqModal.targetFaqId" :options="faqOptions" placeholder="搜索并选择" filterable /></t-form-item>
<t-form-item v-if="faqModal.mode === 'edit'" label="修正后的答案"><t-textarea v-model="faqModal.answer" :autosize="{ minRows: 3, maxRows: 6 }" /></t-form-item>
<t-form-item v-if="faqModal.mode === 'create'" label="分类"><t-select v-model="faqModal.categoryId" :options="categorySelectOptions" placeholder="不分类" clearable /></t-form-item>
<t-form-item v-if="faqModal.mode === 'create'" label="优先级"><t-input-number v-model="faqModal.priority" :min="0" style="width:100%;" /></t-form-item>
</t-form>
<div class="dialog-footer">
<t-button variant="outline" @click="faqModal.visible = false">取消</t-button>
<t-button theme="primary" @click="submitFaqAction" :disabled="!canSubmit">确定</t-button>
</div>
</t-dialog>
</t-card>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { listFeedbackConversations, getFeedbackMessages, markFeedbackProcessed } from '@/api/feedback'
import { listFaqs, createFaqFromFeedback, appendFaqFromFeedback, editFaqFromFeedback } from '@/api/faq'
import { getCategoryTree } from '@/api/category'
import { toast } from '@/utils/toast'
import { renderMarkdown } from '@/utils/markdown'
const filterFeedbackType = ref(''); const filterReasonCategory = ref(''); const filterProcessed = ref(''); const filterDate = ref('')
const feedbacks = ref<any[]>([]); const loading = ref(false)
const page = ref(1); const pageSize = ref(20); const total = ref(0)
const detailModal = ref({ visible: false, messages: [] as any[] })
const faqModal = ref<{
visible: boolean; title: string; mode: 'create' | 'append' | 'edit';
question: string; answer: string; similarQuestion: string;
targetFaqId: string; categoryId: any; priority: number;
sourceRow: any;
}>({ visible: false, title: '', mode: 'create', question: '', answer: '', similarQuestion: '', targetFaqId: '', categoryId: null, priority: 0, sourceRow: null })
const faqList = ref<any[]>([]); const flatCategories = ref<any[]>([])
const feedbackTypeOptions = [
{ label: '👍 有帮助', value: 'THUMBS_UP' },
{ label: '👎 没帮助', value: 'THUMBS_DOWN' },
]
const reasonCategoryOptions = [
{ label: '不准确', value: 'inaccurate' },
{ label: '不相关', value: 'irrelevant' },
{ label: '不完整', value: 'incomplete' },
{ label: '其他', value: 'other' },
]
const processedOptions = [
{ label: '待处理', value: 'false' },
{ label: '已处理', value: 'true' },
]
const columns = [
{ colKey: 'feedbackType', title: '反馈', width: 90, cell: 'feedbackType' },
{ colKey: 'reasonCategory', title: '原因', width: 80, cell: 'reasonCategory' },
{ colKey: 'userQuestion', title: '用户问题', width: 200, ellipsis: true, cell: 'userQuestion' },
{ colKey: 'assistantAnswer', title: 'AI 回复', width: 200, ellipsis: true, cell: 'assistantAnswer' },
{ colKey: 'processed', title: '状态', width: 70, cell: 'processed' },
{ colKey: 'feedbackTime', title: '时间', width: 150 },
{ colKey: 'op', title: '操作', width: 240, cell: 'op' },
]
const canSubmit = computed(() => {
const m = faqModal.value
if (m.mode === 'create') return !!(m.question && m.answer)
if (m.mode === 'append') return !!(m.targetFaqId && m.similarQuestion)
if (m.mode === 'edit') return !!(m.targetFaqId && m.answer)
return false
})
const categorySelectOptions = computed(() =>
flatCategories.value.map((c: any) => ({ label: c.path || c.name, value: c.id }))
)
const faqOptions = computed(() =>
faqList.value.map((f: any) => ({ label: f.question, value: String(f.id) }))
)
function reasonLabel(key: string): string {
const map: Record<string, string> = { inaccurate: '不准确', irrelevant: '不相关', incomplete: '不完整', other: '其他' }
return map[key] || key || '-'
}
function truncate(text: string, maxLen: number): string {
if (!text) return ''
return text.length > maxLen ? text.substring(0, maxLen) + '…' : text
}
/** 将后端返回的 snake_case 字段归一化为 camelCase,统一后续访问方式 */
function normalizeFeedbackRow(item: any): any {
return {
...item,
feedbackId: item.feedback_id || item.feedbackId,
feedbackType: item.feedback_type || item.feedbackType,
reasonCategory: item.reason_category || item.reasonCategory,
reasonComment: item.reason_comment || item.reasonComment,
feedbackTime: item.feedback_time || item.feedbackTime,
userQuestion: item.user_question || item.userQuestion,
assistantAnswer: item.assistant_answer || item.assistantAnswer,
conversationId: item.conversation_id || item.conversationId,
}
}
onMounted(() => { loadList(1); loadFaqList(); loadCategories() })
async function loadFaqList() { try { const r = await listFaqs(1, 100); if (r.success) faqList.value = r.data?.records || r.data || [] } catch {/* */} }
async function loadCategories() {
try { const r = await getCategoryTree(); if (r.success) { flatCategories.value = flattenTree(r.data) } } catch {/* */}
}
function flattenTree(tree: any[], prefix = ''): any[] {
const r: any[] = []
for (const n of (tree || [])) { const p = prefix ? `${prefix}/${n.name}` : n.name; r.push({ id: n.id, name: n.name, path: p }); if (n.children?.length) r.push(...flattenTree(n.children, p)) }
return r
}
async function loadList(p: number) {
page.value = p; loading.value = true
try {
const params: Record<string, any> = { page: p, size: pageSize.value }
if (filterFeedbackType.value) params.feedbackType = filterFeedbackType.value
if (filterReasonCategory.value) params.reasonCategory = filterReasonCategory.value
if (filterProcessed.value) params.processed = filterProcessed.value === 'true'
if (filterDate.value) {
// TDesign DatePicker
if (Array.isArray(filterDate.value) && filterDate.value.length >= 2) {
params.startDate = filterDate.value[0]
params.endDate = filterDate.value[1]
}
}
const r = await listFeedbackConversations(params)
if (r.success) {
feedbacks.value = (r.data?.records || []).map(normalizeFeedbackRow)
total.value = r.data?.total || 0
}
else toast(r.message || '查询失败', 'error')
} catch (e: any) { toast('加载失败:' + e.message, 'error') }
finally { loading.value = false }
}
function onPageChange(i: { current: number; pageSize: number }) { pageSize.value = i.pageSize; loadList(i.current) }
async function viewDetail(row: any) {
try {
const r = await getFeedbackMessages(row.conversationId || row.conversation_id)
detailModal.value.visible = true; detailModal.value.messages = r.data || []
} catch (e: any) { toast('加载消息失败:' + e.message, 'error') }
}
async function markHandled(row: any) {
const fbId = row.feedbackId || row.feedback_id
if (!fbId) { toast('未找到反馈记录', 'error'); return }
try {
await markFeedbackProcessed(fbId, { remark: '手动标记已处理' })
toast('已标记', 'success')
loadList(page.value)
} catch (e: any) { toast('操作失败:' + e.message, 'error') }
}
function createFaqFromRow(row: any) {
// SQL user_question / userQuestion
faqModal.value = {
visible: true, title: '从点赞反馈生成 FAQ', mode: 'create',
question: truncate(row.userQuestion || row.user_question || '', 500),
answer: row.assistantAnswer || row.assistant_answer || '',
similarQuestion: '', targetFaqId: '', categoryId: null, priority: 0, sourceRow: row,
}
}
function openFaqAction(row: any) {
loadFaqList()
const q = truncate(row.userQuestion || row.user_question || '', 500)
if (row.reasonCategory === 'inaccurate' || row.reasonCategory === 'incomplete') {
faqModal.value = {
visible: true, title: '修正 FAQ 答案', mode: 'edit',
question: '', answer: row.assistantAnswer || row.assistant_answer || '',
similarQuestion: '', targetFaqId: '', categoryId: null, priority: 0, sourceRow: row,
}
} else {
faqModal.value = {
visible: true, title: '补充到已有 FAQ', mode: 'append',
question: '', answer: '', similarQuestion: q,
targetFaqId: '', categoryId: null, priority: 0, sourceRow: row,
}
}
}
async function submitFaqAction() {
const m = faqModal.value
const row = m.sourceRow
const userQ = (row.userQuestion || row.user_question || '').substring(0, 500)
const aiA = (row.assistantAnswer || row.assistant_answer || '').substring(0, 10000)
const fbId = row.feedbackId || row.feedback_id
try {
if (m.mode === 'create') {
const r = await createFaqFromFeedback({
feedbackId: fbId,
question: m.question, answer: m.answer,
categoryId: m.categoryId, priority: m.priority,
source: 'feedback_positive',
originalQuestion: userQ, originalAnswer: aiA,
})
if (r.success) { toast('FAQ 创建成功', 'success'); faqModal.value.visible = false; loadList(page.value) }
else toast(r.message || '创建失败', 'error')
} else if (m.mode === 'append') {
const r = await appendFaqFromFeedback(m.targetFaqId, {
feedbackId: fbId, similarQuestion: m.similarQuestion, originalAnswer: aiA,
})
if (r.success) { toast('补充成功', 'success'); faqModal.value.visible = false; loadList(page.value) }
else toast(r.message || '补充失败', 'error')
} else if (m.mode === 'edit') {
const r = await editFaqFromFeedback(m.targetFaqId, {
feedbackId: fbId, answer: m.answer, originalQuestion: userQ,
})
if (r.success) { toast('修正成功', 'success'); faqModal.value.visible = false; loadList(page.value) }
else toast(r.message || '修正失败', 'error')
}
} catch (e: any) { toast('操作失败:' + e.message, 'error') }
}
</script>
<style scoped>
.msg-item { padding: 12px; border-radius: 8px; margin-bottom: 12px; }
.msg-user { background: #eff6ff; }
.msg-assistant { background: #f9fafb; }
.msg-system { background: #fef3c7; }
.msg-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.msg-body { font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
.dialog-footer { margin-top: 16px; display: flex; justify-content: flex-end; gap: 8px; }
</style>

114
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,6 +716,61 @@ 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)");
}
/**
* 为已存在的 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");
}
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 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 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 知识库 ====================
@ -719,6 +788,7 @@ public class DatabaseInitConfig {
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
@ -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=已删除");

145
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<Map<String, Object>> createFromFeedback(@RequestBody Map<String, Object> 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<Map<String, Object>> appendFromFeedback(
@PathVariable Long id,
@RequestBody Map<String, Object> 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<Map<String, Object>> editFromFeedback(
@PathVariable Long id,
@RequestBody Map<String, Object> 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<Map<String, Object>> getFeedbackLinks(@PathVariable Long id) {
try {
List<FaqFeedbackLink> 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()));
}
}
// ==================== 新增 ====================
/**

82
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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> getMessagesWithFeedback(
@RequestParam String conversationId) {
try {
List<Map<String, Object>> 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<Map<String, Object>> markProcessed(
@PathVariable Long id,
@RequestBody Map<String, Object> 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()
));
}
}
/**
* 获取反馈统计数据
*

34
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<FaqFeedbackLink> {
/**
* 根据 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<FaqFeedbackLink> 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<FaqFeedbackLink> selectByFeedbackId(@Param("feedbackId") Long feedbackId);
}

94
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;
}

9
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;
/**
* 创建时间
*/

19
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;
/**
* 创建时间
*/

198
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<String, Object> 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<KnowledgeFaq> 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<String> 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<FaqFeedbackLink> getFeedbackLinksByFaqId(Long faqId) {
return faqFeedbackLinkMapper.selectByFaqId(faqId);
}
/**
* 解析相似问题 JSON 字符串为列表
*/
private List<String> parseSimilarQuestions(String json) {
if (json == null || json.isBlank()) {
return new ArrayList<>();
}
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("解析相似问题失败: {}", json, e);
return new ArrayList<>();
}
}
/**
* 将相似问题列表序列化为 JSON 字符串
*/
private String toJsonString(List<String> 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;
}

184
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<String, Object> 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<Object> 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<Map<String, Object>> rows = jdbcTemplate.queryForList(querySql, params.toArray());
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", total != null ? total : 0L);
result.put("records", rows);
return result;
}
/**
* 按会话 ID 返回带反馈标记的消息列表
*
* @param conversationId 会话ID
* @return 消息列表包含 feedback 对象
*/
public List<Map<String, Object>> getMessagesWithFeedback(String conversationId) {
// 先查询该会话下所有反馈
List<MessageFeedback> feedbacks = getByConversationId(conversationId);
Map<String, MessageFeedback> 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<Map<String, Object>> messages = jdbcTemplate.queryForList(sql, conversationId);
// AI 消息附加反馈信息
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> message : messages) {
Map<String, Object> 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<String, Object> 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查询所有反馈
*

41
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 精准匹配)
-- ============================================================
@ -498,6 +535,7 @@ CREATE TABLE IF NOT EXISTS knowledge_faq (
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 NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_delete BOOLEAN NOT NULL DEFAULT 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=已删除';

Loading…
Cancel
Save