Browse Source

feat(P0): 完成阶段一全部需求 — 混合检索/内容安全/用户反馈/FAQ精准匹配

P0-001 混合检索+重排序引擎:
- HybridSearchService 支持 VECTOR/KEYWORD/HYBRID 三种检索模式
- RrfFusion RRF 融合算法 (k=60)
- RerankerService 支持 DashScope + OpenAI 兼容多提供商
- vector_store 新增 content_tsvector 列 + GIN 索引 + 触发器
- DocSearch.js 增加检索模式下拉选择

P0-004 内容安全过滤:
- ContentSafetyService DFA 字典树引擎(volatile + copy-on-write 线程安全)
- ContentSafetyAdvisor BaseAdvisor 实现,输入/输出双向审核
- SensitiveWordService 敏感词 CRUD + 批量导入 + 字典树热加载
- SensitiveWordManager.js 前端管理组件

P0-002 用户反馈系统:
- MessageFeedbackService upsert 语义反馈提交 + 统计 API
- ChatPanel.js 添加 👍/👎 反馈按钮
- Chat SDK handleFeedback() 对接后端 POST /feedback
- ConversationService 导出集成反馈信息

P0-003 意图识别+FAQ精准匹配:
- IntentRouter LLM 意图分类(FAQ/RAG/CHITCHAT)
- FaqMatchEngine 三级匹配(精确→关键词→向量语义,阈值 0.85)
- FaqService FAQ CRUD + 异步向量化
- FaqManager.js 前端管理(CRUD + 批量导入/导出 + 启用/禁用)

共享变更:
- DatabaseInitConfig 新增 5 张表 + 全文检索初始化
- api.js 新增反馈/敏感词/FAQ API 封装
- app.js 注册 SensitiveWordManager + FaqManager 组件
- application.yml 新增 knowledge.faq.semantic-threshold 配置
dev-mcp
wanghanlin 4 weeks ago
parent
commit
f7eb0bfaf1
  1. 34
      CLAUDE.md
  2. 49
      client/dist/chatbot-sdk.js
  3. 2
      client/dist/chatbot-sdk.js.map
  4. 2
      client/dist/chatbot-sdk.min.js
  5. 2
      client/dist/chatbot-sdk.min.js.map
  6. 33
      client/src/api.ts
  7. 18
      client/src/chat.ts
  8. 228
      src/main/java/com/wok/supportbot/advisor/ContentSafetyAdvisor.java
  9. 5
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  10. 351
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  11. 48
      src/main/java/com/wok/supportbot/controller/AiController.java
  12. 8
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  13. 277
      src/main/java/com/wok/supportbot/controller/FaqController.java
  14. 146
      src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java
  15. 239
      src/main/java/com/wok/supportbot/controller/SensitiveWordController.java
  16. 12
      src/main/java/com/wok/supportbot/dao/ContentAuditLogMapper.java
  17. 12
      src/main/java/com/wok/supportbot/dao/KnowledgeFaqMapper.java
  18. 12
      src/main/java/com/wok/supportbot/dao/MessageFeedbackMapper.java
  19. 12
      src/main/java/com/wok/supportbot/dao/SensitiveWordMapper.java
  20. 59
      src/main/java/com/wok/supportbot/entity/ContentAuditLog.java
  21. 102
      src/main/java/com/wok/supportbot/entity/KnowledgeFaq.java
  22. 86
      src/main/java/com/wok/supportbot/entity/MessageFeedback.java
  23. 5
      src/main/java/com/wok/supportbot/entity/SearchResult.java
  24. 66
      src/main/java/com/wok/supportbot/entity/SensitiveWord.java
  25. 283
      src/main/java/com/wok/supportbot/rag/HybridSearchService.java
  26. 236
      src/main/java/com/wok/supportbot/rag/RerankerService.java
  27. 88
      src/main/java/com/wok/supportbot/rag/RrfFusion.java
  28. 19
      src/main/java/com/wok/supportbot/rag/SearchMode.java
  29. 209
      src/main/java/com/wok/supportbot/service/ContentSafetyService.java
  30. 52
      src/main/java/com/wok/supportbot/service/ConversationService.java
  31. 33
      src/main/java/com/wok/supportbot/service/DocumentService.java
  32. 363
      src/main/java/com/wok/supportbot/service/FaqMatchEngine.java
  33. 299
      src/main/java/com/wok/supportbot/service/FaqService.java
  34. 119
      src/main/java/com/wok/supportbot/service/IntentRouter.java
  35. 178
      src/main/java/com/wok/supportbot/service/MessageFeedbackService.java
  36. 209
      src/main/java/com/wok/supportbot/service/SensitiveWordService.java
  37. 6
      src/main/resources/add-comments.sql
  38. 3
      src/main/resources/application.yml
  39. 210
      src/main/resources/init-database.sql
  40. 4
      src/main/resources/knowledge-base.sql
  41. 41
      src/main/resources/static/components/ChatPanel.js
  42. 42
      src/main/resources/static/components/DocSearch.js
  43. 299
      src/main/resources/static/components/FaqManager.js
  44. 304
      src/main/resources/static/components/SensitiveWordManager.js
  45. 150
      src/main/resources/static/js/api.js
  46. 6
      src/main/resources/static/js/app.js
  47. 49
      src/main/resources/static/sdk/chatbot-sdk.js
  48. 2
      src/main/resources/static/sdk/chatbot-sdk.js.map
  49. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js
  50. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js.map

34
CLAUDE.md

@ -123,6 +123,40 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- 文档管理: `/document/*`(`DocumentController`)
- 批量操作: `/document/batch/*`(`DocumentController`,用 POST 避免 DELETE+RequestBody 路径冲突)
- 分类管理: `/category/*`(`DocumentController`)
- 消息反馈: `/feedback/*`(`MessageFeedbackController`)
- 敏感词管理: `/sensitive-word/*`(`SensitiveWordController`)
- FAQ 管理: `/faq/*`(`FaqController`)
## P0 阶段新增功能
### 内容安全过滤(P0-004)
- **DFA 引擎**: `ContentSafetyService` 使用字典树匹配敏感词,`volatile` + copy-on-write 保证线程安全热加载
- **ContentSafetyAdvisor**: 实现 `BaseAdvisor`,`getOrder()` 返回 `HIGHEST_PRECEDENCE`(Advisor 链最外层),before 阶段检查用户输入、after 阶段检查 AI 输出
- **敏感词级别**: level=1 仅脱敏(MASK),level=2 拦截(BLOCK)返回安全提示
- **审计日志**: `content_audit_log` 表记录所有违规事件,不删除
- **前端**: `SensitiveWordManager.js` 在系统设置 Tab,支持 CRUD + 批量导入 + 审计日志查看
### 用户反馈系统(P0-002)
- **反馈实体**: `MessageFeedback`,按 `message_id` 唯一索引,重复提交覆盖(upsert 语义)
- **反馈类型**: THUMBS_UP(有帮助)/ THUMBS_DOWN(没帮助),点踩可选原因分类 + 自由文本
- **ChatPanel**: 每条 AI 回复下方有 👍/👎 按钮,点击调用 `POST /feedback`
- **Chat SDK**: `handleFeedback()` 已连接后端 API,同时保留 localStorage 作为乐观 UI 缓存
- **会话导出**: `ConversationService.exportConversation()` 导出的 TXT 中包含反馈信息
### 意图识别 + FAQ 精准匹配(P0-003)
- **IntentRouter**: LLM 单次调用做意图分类(FAQ/RAG/CHITCHAT),解析失败降级为 RAG
- **FaqMatchEngine**: 三级匹配策略 — 精确匹配 → 关键词匹配 → 向量语义匹配(阈值 `knowledge.faq.semantic-threshold`,默认 0.85)
- **FAQ 向量化**: 复用现有 `DynamicEmbeddingModel`,向量存入 `faq_embedding` 表,新增/修改 FAQ 时异步计算
- **similar_questions 字段**: 使用 String 类型存储 JSON 数组字符串(PostgresJsonTypeHandler 期望对象格式,故不用 typeHandler)
- **前端**: `FaqManager.js` 在知识库文档管理 Tab,支持 CRUD + 批量 JSON 导入/导出 + 启用/禁用
### 混合检索 + 重排序引擎(P0-001)
- **SearchMode**: 枚举 VECTOR(默认)/ KEYWORD / HYBRID,向后兼容
- **HybridSearchService**: 多模式检索核心,KEYWORD 使用 PostgreSQL `tsvector` 全文检索,HYBRID 使用双路检索 + RRF 融合
- **RrfFusion**: RRF 融合算法 `score = Σ 1/(k + rank_i)`,k=60
- **RerankerService**: 支持 DashScope + OpenAI 兼容提供商,通过 `ai_model_config` 表 RERANK 类型配置,超时 3s 自动 fallback
- **vector_store 全文检索**: 新增 `content_tsvector` 列 + GIN 索引 + PostgreSQL 触发器自动维护
- **前端**: `DocSearch.js` 增加检索模式下拉选择(向量/关键词/混合),结果标注来源模式
## 已知 TODO

49
client/dist/chatbot-sdk.js

@ -755,6 +755,36 @@ var ChatbotSDK = (function () {
return [];
}
}
// ==================== P0-002: 消息反馈 ====================
/**
* 提交消息反馈点赞/点踩
*/
async function submitFeedbackApi(messageId, feedbackType) {
if (!currentConfig)
return false;
const url = buildUrl('/feedback');
try {
const response = await safeFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messageId: String(messageId),
conversationId: currentConfig.chatId,
feedbackType,
}),
});
if (!response.ok) {
logger.error(`反馈提交失败 status=${response.status}`);
return false;
}
const json = await response.json();
return json.success || false;
}
catch (err) {
logger.error('反馈提交异常', err);
return false;
}
}
/**
* 获取会话列表
*/
@ -3654,8 +3684,7 @@ var ChatbotSDK = (function () {
// ==================== 消息反馈(👍 / 👎) ====================
/**
* 处理消息反馈切换 AI 消息的点赞/点踩状态
* 当前仅前端状态持久化存入 messages 数组 + localStorage
* 预留后端对接位TODO 接口 POST /conversation/message/feedback
* 前端状态持久化存入 messages 数组 + localStorage+ 调用后端 API 记录反馈
*/
function handleFeedback(msgId, value) {
if (!messagesContainer$1)
@ -3673,7 +3702,21 @@ var ChatbotSDK = (function () {
// 持久化(localStorage)
if (config$1)
saveMessages(config$1.integrateId, messages);
logger.info(`消息反馈 msgId=${msgId} value=${newValue || 'cleared'}`);
// 调用后端 API 记录反馈
if (newValue) {
const feedbackType = newValue === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN';
submitFeedbackApi(String(msgId), feedbackType).then(success => {
if (success) {
logger.info(`消息反馈已提交 msgId=${msgId} value=${newValue}`);
}
else {
logger.warn(`消息反馈提交失败 msgId=${msgId}(本地状态已更新)`);
}
});
}
else {
logger.info(`消息反馈已取消 msgId=${msgId}`);
}
}
function autoResizeInput() {
if (!inputEl$1)

2
client/dist/chatbot-sdk.js.map
File diff suppressed because it is too large
View File

2
client/dist/chatbot-sdk.min.js
File diff suppressed because it is too large
View File

2
client/dist/chatbot-sdk.min.js.map
File diff suppressed because it is too large
View File

33
client/src/api.ts

@ -376,6 +376,39 @@ export async function fetchRagSources(message: string, categoryId?: number): Pro
}
}
// ==================== P0-002: 消息反馈 ====================
/**
* /
*/
export async function submitFeedbackApi(
messageId: string,
feedbackType: 'THUMBS_UP' | 'THUMBS_DOWN'
): Promise<boolean> {
if (!currentConfig) return false;
const url = buildUrl('/feedback');
try {
const response = await safeFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messageId: String(messageId),
conversationId: currentConfig.chatId,
feedbackType,
}),
});
if (!response.ok) {
logger.error(`反馈提交失败 status=${response.status}`);
return false;
}
const json: ApiResponse = await response.json();
return json.success || false;
} catch (err) {
logger.error('反馈提交异常', err);
return false;
}
}
// ==================== P2: 会话管理 + chatId 初始化 ====================
/** 会话列表项 */

18
client/src/chat.ts

@ -20,6 +20,7 @@ import {
updateChatId,
getChatId,
saveCachedChatId,
submitFeedbackApi,
CskError,
} from './api';
import {
@ -342,8 +343,7 @@ function announceMessage(text: string): void {
/**
* AI /
* messages + localStorage
* TODO POST /conversation/message/feedback
* messages + localStorage+ API
*/
export function handleFeedback(msgId: string, value: 'up' | 'down'): void {
if (!messagesContainer) return;
@ -362,7 +362,19 @@ export function handleFeedback(msgId: string, value: 'up' | 'down'): void {
// 持久化(localStorage)
if (config) saveMessages(config.integrateId, messages);
logger.info(`消息反馈 msgId=${msgId} value=${newValue || 'cleared'}`);
// 调用后端 API 记录反馈
if (newValue) {
const feedbackType = newValue === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN';
submitFeedbackApi(String(msgId), feedbackType).then(success => {
if (success) {
logger.info(`消息反馈已提交 msgId=${msgId} value=${newValue}`);
} else {
logger.warn(`消息反馈提交失败 msgId=${msgId}(本地状态已更新)`);
}
});
} else {
logger.info(`消息反馈已取消 msgId=${msgId}`);
}
}
function autoResizeInput(): void {
if (!inputEl) return;

228
src/main/java/com/wok/supportbot/advisor/ContentSafetyAdvisor.java

@ -0,0 +1,228 @@
package com.wok.supportbot.advisor;
import com.wok.supportbot.dao.ContentAuditLogMapper;
import com.wok.supportbot.entity.ContentAuditLog;
import com.wok.supportbot.service.ContentSafetyService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.AdvisorChain;
import org.springframework.ai.chat.client.advisor.api.BaseAdvisor;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.stream.Collectors;
/**
* 内容安全过滤 Advisor
* <p>
* 在对话链路的最外层拦截对用户输入和 AI 输出进行敏感词检测与脱敏/拦截处理
* 使用 Ordered.HIGHEST_PRECEDENCE 确保最先执行 before最后执行 after
*/
@Slf4j
@Component
public class ContentSafetyAdvisor implements BaseAdvisor {
/** 安全提示文本 —— 当用户消息被 BLOCK 时替换原始内容 */
private static final String SAFE_USER_PROMPT = "系统检测到不当内容";
/** 安全提示文本 —— 当 AI 输出被拦截时替换原始内容 */
private static final String SAFE_AI_RESPONSE = "抱歉,该内容无法展示。如有问题请联系管理员。";
@Autowired
private ContentSafetyService contentSafetyService;
@Autowired
private ContentAuditLogMapper contentAuditLogMapper;
@Override
public String getName() {
return this.getClass().getSimpleName();
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
// ==================== before检查用户输入 ====================
@Override
public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
try {
Prompt originalPrompt = request.prompt();
List<Message> messages = new ArrayList<>(originalPrompt.getInstructions());
// 提取最后一条用户消息进行检测
String userText = null;
int lastUserIndex = -1;
for (int i = messages.size() - 1; i >= 0; i--) {
if (messages.get(i) instanceof UserMessage) {
userText = messages.get(i).getText();
lastUserIndex = i;
break;
}
}
if (userText == null || userText.isBlank()) {
return request;
}
// 执行 DFA 检测
List<ContentSafetyService.HitResult> hits = contentSafetyService.detect(userText);
if (hits.isEmpty()) {
return request;
}
boolean hasBlocking = contentSafetyService.hasBlockingHit(hits);
String sessionId = extractSessionId(request);
if (hasBlocking) {
// BLOCK 级别替换用户消息为安全提示记录审计日志
log.warn("内容安全拦截(BLOCK)- 会话:{},命中词:{}", sessionId,
hits.stream().map(ContentSafetyService.HitResult::getWord).collect(Collectors.joining(", ")));
messages.set(lastUserIndex, new UserMessage(SAFE_USER_PROMPT));
Prompt newPrompt = new Prompt(messages, originalPrompt.getOptions());
saveAuditLog(sessionId, "INPUT", truncate(userText), hits, "BLOCK");
return ChatClientRequest.builder()
.prompt(newPrompt)
.context(request.context())
.build();
} else {
// WARN 级别脱敏后替换用户消息记录审计日志
String maskedText = contentSafetyService.mask(userText);
log.info("内容安全脱敏(MASK)- 会话:{},命中词:{}", sessionId,
hits.stream().map(ContentSafetyService.HitResult::getWord).collect(Collectors.joining(", ")));
messages.set(lastUserIndex, new UserMessage(maskedText));
Prompt newPrompt = new Prompt(messages, originalPrompt.getOptions());
saveAuditLog(sessionId, "INPUT", truncate(userText), hits, "MASK");
return ChatClientRequest.builder()
.prompt(newPrompt)
.context(request.context())
.build();
}
} catch (Exception e) {
log.error("内容安全 before 检查异常,放行请求", e);
return request;
}
}
// ==================== after检查 AI 输出 ====================
@Override
public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) {
try {
ChatResponse chatResponse = response.chatResponse();
if (chatResponse == null || chatResponse.getResult() == null) {
return response;
}
String aiText = chatResponse.getResult().getOutput().getText();
if (aiText == null || aiText.isBlank()) {
return response;
}
// 执行 DFA 检测
List<ContentSafetyService.HitResult> hits = contentSafetyService.detect(aiText);
if (hits.isEmpty()) {
return response;
}
boolean hasBlocking = contentSafetyService.hasBlockingHit(hits);
String actionTaken = hasBlocking ? "BLOCK" : "MASK";
log.warn("AI 输出内容安全拦截({})- 命中词:{}", actionTaken,
hits.stream().map(ContentSafetyService.HitResult::getWord).collect(Collectors.joining(", ")));
// 构建替换后的 AI 响应
String safeText = hasBlocking ? SAFE_AI_RESPONSE : contentSafetyService.mask(aiText);
AssistantMessage safeAssistantMessage = new AssistantMessage(safeText);
Generation safeGeneration = new Generation(safeAssistantMessage, chatResponse.getResult().getMetadata());
ChatResponse safeChatResponse = new ChatResponse(
Collections.singletonList(safeGeneration),
chatResponse.getMetadata()
);
// 保存审计日志此处 sessionId 可能为空因为 after 阶段不一定能获取到
saveAuditLog("", "OUTPUT", truncate(aiText), hits, actionTaken);
return ChatClientResponse.builder()
.chatResponse(safeChatResponse)
.context(response.context())
.build();
} catch (Exception e) {
log.error("内容安全 after 检查异常,放行响应", e);
return response;
}
}
// ==================== 辅助方法 ====================
/**
* 从请求上下文中提取会话ID
*/
private String extractSessionId(ChatClientRequest request) {
Map<String, Object> context = request.context();
if (context != null && context.containsKey("chatMemoryConversationId")) {
return String.valueOf(context.get("chatMemoryConversationId"));
}
return "";
}
/**
* 截断文本至指定长度
*/
private String truncate(String text) {
if (text == null) {
return "";
}
return text.length() > 50 ? text.substring(0, 50) + "..." : text;
}
/**
* 保存审计日志
*/
private void saveAuditLog(String sessionId, String direction, String originalText,
List<ContentSafetyService.HitResult> hits, String actionTaken) {
try {
// 将命中结果转为 Map 列表存入 JSONB
List<Map<String, Object>> hitWordsList = hits.stream().map(hit -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("word", hit.getWord());
m.put("category", hit.getCategory());
m.put("level", hit.getLevel());
return m;
}).collect(Collectors.toList());
Map<String, Object> hitWordsMap = new LinkedHashMap<>();
hitWordsMap.put("hits", hitWordsList);
ContentAuditLog auditLog = ContentAuditLog.builder()
.sessionId(sessionId)
.direction(direction)
.originalText(originalText)
.hitWords(hitWordsMap)
.actionTaken(actionTaken)
.createTime(new Date())
.build();
contentAuditLogMapper.insert(auditLog);
} catch (Exception e) {
log.error("保存审计日志失败", e);
}
}
}

5
src/main/java/com/wok/supportbot/app/AssistantApp.java

@ -1,5 +1,6 @@
package com.wok.supportbot.app;
import com.wok.supportbot.advisor.ContentSafetyAdvisor;
import com.wok.supportbot.advisor.MyLoggerAdvisor;
import com.wok.supportbot.advisor.ReReadingAdvisor;
import com.wok.supportbot.chatmemory.DatabaseChatMemory;
@ -56,6 +57,9 @@ public class AssistantApp {
@Resource
private VectorStore pgVectorVectorStore;
@Resource
private ContentSafetyAdvisor contentSafetyAdvisor;
private final ChatModelFactory chatModelFactory;
private final DatabaseChatMemory chatMemory;
@ -81,6 +85,7 @@ public class AssistantApp {
return ChatClient.builder(chatModel)
.defaultSystem(SYSTEM_PROMPT)
.defaultAdvisors(
contentSafetyAdvisor,
MessageChatMemoryAdvisor.builder(chatMemory).build(),
new MyLoggerAdvisor()
)

351
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -3,6 +3,7 @@ package com.wok.supportbot.config;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@ -17,6 +18,9 @@ public class DatabaseInitConfig {
@Autowired
private JdbcTemplate jdbcTemplate;
@Value("${knowledge.vector.dimension:1024}")
private int vectorDimension;
@PostConstruct
public void init() {
try {
@ -75,6 +79,40 @@ public class DatabaseInitConfig {
syncDefaultCustomerServiceRoles();
syncDefaultCustomerAccounts();
// ==================== P0 阶段新增表 ====================
// P0-004: 内容安全过滤
if (!checkTableExists("sensitive_word")) {
log.info("创建敏感词表 sensitive_word");
createSensitiveWordTable();
}
if (!checkTableExists("content_audit_log")) {
log.info("创建内容审计日志表 content_audit_log");
createContentAuditLogTable();
}
// P0-002: 用户反馈
if (!checkTableExists("message_feedback")) {
log.info("创建消息反馈表 message_feedback");
createMessageFeedbackTable();
}
// P0-003: FAQ 知识库
if (!checkTableExists("knowledge_faq")) {
log.info("创建 FAQ 知识库表 knowledge_faq");
createKnowledgeFaqTable();
}
if (!checkTableExists("faq_embedding")) {
log.info("创建 FAQ 向量索引表 faq_embedding");
createFaqEmbeddingTable();
}
// P0-001: 混合检索 - vector_store 添加全文检索列
initVectorStoreFullTextSearch();
// 为所有表添加注释幂等可重复执行
applyTableComments();
log.info("数据库初始化完成");
} catch (Exception e) {
log.error("数据库初始化失败", e);
@ -348,4 +386,317 @@ public class DatabaseInitConfig {
}
}
// ==================== P0-004: 内容安全过滤 ====================
private void createSensitiveWordTable() {
String sql = """
CREATE TABLE IF NOT EXISTS sensitive_word (
id BIGSERIAL PRIMARY KEY,
word VARCHAR(256) NOT NULL,
category VARCHAR(64) NOT NULL DEFAULT 'custom',
level INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
remark VARCHAR(512),
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 UNIQUE INDEX IF NOT EXISTS uk_sensitive_word_word_category ON sensitive_word (word, category) WHERE is_delete = false");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_sensitive_word_active ON sensitive_word (is_active) WHERE is_delete = false");
}
private void createContentAuditLogTable() {
String sql = """
CREATE TABLE IF NOT EXISTS content_audit_log (
id BIGSERIAL PRIMARY KEY,
session_id VARCHAR(64),
direction VARCHAR(16) NOT NULL,
original_text TEXT NOT NULL,
hit_words JSONB DEFAULT '[]' NOT NULL,
action_taken VARCHAR(32) NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""";
jdbcTemplate.execute(sql);
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_created ON content_audit_log (create_time DESC)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_session ON content_audit_log (session_id)");
}
// ==================== P0-002: 用户反馈 ====================
private void createMessageFeedbackTable() {
String sql = """
CREATE TABLE IF NOT EXISTS message_feedback (
id BIGSERIAL PRIMARY KEY,
message_id VARCHAR(128) NOT NULL,
conversation_id VARCHAR(64) NOT NULL,
feedback_type VARCHAR(16) NOT NULL,
reason_category VARCHAR(64),
reason_comment 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 UNIQUE INDEX IF NOT EXISTS uk_message_feedback_message ON message_feedback (message_id) WHERE is_delete = false");
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)");
}
// ==================== 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),
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 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_priority ON knowledge_faq (priority DESC)");
}
private void createFaqEmbeddingTable() {
String sql = """
CREATE TABLE IF NOT EXISTS faq_embedding (
id BIGSERIAL PRIMARY KEY,
faq_id BIGINT NOT NULL,
embedding vector(%d) NOT NULL,
model_name VARCHAR(64) NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""".formatted(vectorDimension);
jdbcTemplate.execute(sql);
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_emb_faq_id ON faq_embedding (faq_id)");
}
// ==================== P0-001: 混合检索 - 全文检索 ====================
/**
* vector_store 表添加全文检索支持tsvector + GIN 索引 + 触发器
* 幂等操作已存在则跳过
*/
private void initVectorStoreFullTextSearch() {
try {
// 检查 vector_store 表是否存在
if (!checkTableExists("vector_store")) {
log.debug("vector_store 表尚未创建,跳过全文检索初始化");
return;
}
// 检查 content_tsvector 列是否已存在
String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'vector_store' AND column_name = 'content_tsvector'";
Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class);
if (count != null && count > 0) {
return; // 已初始化
}
log.info("为 vector_store 添加全文检索支持");
// 添加 tsvector
jdbcTemplate.execute("ALTER TABLE vector_store ADD COLUMN content_tsvector tsvector");
// 填充已有数据
jdbcTemplate.execute("UPDATE vector_store SET content_tsvector = to_tsvector('simple', coalesce(content, '')) WHERE content_tsvector IS NULL");
// GIN 索引
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_vector_store_tsvector ON vector_store USING gin(content_tsvector)");
// 触发器函数
jdbcTemplate.execute("""
CREATE OR REPLACE FUNCTION update_content_tsvector() RETURNS trigger AS $$
BEGIN
NEW.content_tsvector := to_tsvector('simple', coalesce(NEW.content, ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql
""");
// 绑定触发器
jdbcTemplate.execute("DROP TRIGGER IF EXISTS trg_update_content_tsvector ON vector_store");
jdbcTemplate.execute("""
CREATE TRIGGER trg_update_content_tsvector
BEFORE INSERT OR UPDATE ON vector_store
FOR EACH ROW EXECUTE FUNCTION update_content_tsvector()
""");
log.info("vector_store 全文检索初始化完成");
} catch (Exception e) {
log.warn("初始化 vector_store 全文检索时出错(可稍后手动执行)", e);
}
}
/**
* 为所有自动创建的表添加注释COMMENT ON
* 所有语句均为幂等操作可安全重复执行
*/
private void applyTableComments() {
try {
// ===== chat_message =====
executeComment("TABLE chat_message", "聊天消息表(存储用户与 AI 助手的对话历史)");
executeComment("COLUMN chat_message.id", "主键(雪花算法生成)");
executeComment("COLUMN chat_message.conversation_id", "会话 ID(标识同一次对话)");
executeComment("COLUMN chat_message.message_type", "消息类型: USER(用户消息) / ASSISTANT(AI回复) / SYSTEM(系统消息)");
executeComment("COLUMN chat_message.content", "消息内容(实际对话文本)");
executeComment("COLUMN chat_message.metadata", "元数据(JSON 格式,存储额外信息)");
executeComment("COLUMN chat_message.create_time", "创建时间");
executeComment("COLUMN chat_message.update_time", "更新时间");
executeComment("COLUMN chat_message.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
// ===== knowledge_category =====
executeComment("TABLE knowledge_category", "知识库分类表(支持树形结构)");
executeComment("COLUMN knowledge_category.id", "主键(雪花算法生成)");
executeComment("COLUMN knowledge_category.name", "分类名称");
executeComment("COLUMN knowledge_category.description", "分类描述");
executeComment("COLUMN knowledge_category.parent_id", "父分类 ID(0 表示顶级分类)");
executeComment("COLUMN knowledge_category.sort_order", "排序权重(数值越大越靠前)");
executeComment("COLUMN knowledge_category.document_count", "关联文档数量(冗余字段,定期更新)");
executeComment("COLUMN knowledge_category.create_time", "创建时间");
executeComment("COLUMN knowledge_category.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
// ===== knowledge_document =====
executeComment("TABLE knowledge_document", "知识文档表(记录上传的文档元信息)");
executeComment("COLUMN knowledge_document.id", "主键(雪花算法生成)");
executeComment("COLUMN knowledge_document.title", "文档标题");
executeComment("COLUMN knowledge_document.source_name", "原始文件名");
executeComment("COLUMN knowledge_document.file_type", "文件类型: pdf / md / json / txt / word / excel 等");
executeComment("COLUMN knowledge_document.file_size", "文件大小(字节)");
executeComment("COLUMN knowledge_document.content", "原文内容(截断预览)");
executeComment("COLUMN knowledge_document.category_id", "所属分类 ID(0 表示未分类)");
executeComment("COLUMN knowledge_document.tags", "标签(JSON 格式)");
executeComment("COLUMN knowledge_document.chunk_count", "分块数量");
executeComment("COLUMN knowledge_document.status", "处理状态: PROCESSING / READY / FAILED");
executeComment("COLUMN knowledge_document.error_message", "处理失败时的错误信息");
executeComment("COLUMN knowledge_document.content_hash", "内容 SHA-256 哈希值(用于文档去重)");
executeComment("COLUMN knowledge_document.create_time", "创建时间");
executeComment("COLUMN knowledge_document.update_time", "更新时间");
executeComment("COLUMN knowledge_document.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
// ===== customer_service_role =====
executeComment("TABLE customer_service_role", "客服角色表(定义客服角色的身份与系统提示词)");
executeComment("COLUMN customer_service_role.id", "主键");
executeComment("COLUMN customer_service_role.role_key", "角色标识符(唯一)");
executeComment("COLUMN customer_service_role.name", "角色名称");
executeComment("COLUMN customer_service_role.description", "角色描述");
executeComment("COLUMN customer_service_role.prompt", "系统提示词(角色人设与行为规范)");
executeComment("COLUMN customer_service_role.sort_order", "排序权重");
executeComment("COLUMN customer_service_role.enabled", "是否启用");
executeComment("COLUMN customer_service_role.create_time", "创建时间");
executeComment("COLUMN customer_service_role.update_time", "更新时间");
executeComment("COLUMN customer_service_role.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
// ===== customer_service_role_category =====
executeComment("TABLE customer_service_role_category", "客服角色知识库关联表(角色与知识库分类的多对多关系)");
executeComment("COLUMN customer_service_role_category.id", "主键");
executeComment("COLUMN customer_service_role_category.role_id", "角色 ID(关联 customer_service_role.id)");
executeComment("COLUMN customer_service_role_category.category_id", "分类 ID(关联 knowledge_category.id)");
executeComment("COLUMN customer_service_role_category.create_time", "创建时间");
executeComment("COLUMN customer_service_role_category.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
// ===== customer_account =====
executeComment("TABLE customer_account", "客服账号表(对外暴露的客服入口账号)");
executeComment("COLUMN customer_account.id", "主键");
executeComment("COLUMN customer_account.account_key", "账号标识符(唯一)");
executeComment("COLUMN customer_account.name", "账号名称");
executeComment("COLUMN customer_account.description", "账号描述");
executeComment("COLUMN customer_account.role_id", "关联角色 ID(关联 customer_service_role.id)");
executeComment("COLUMN customer_account.enabled", "是否启用");
executeComment("COLUMN customer_account.create_time", "创建时间");
executeComment("COLUMN customer_account.update_time", "更新时间");
executeComment("COLUMN customer_account.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
// ===== conversation_session =====
executeComment("TABLE conversation_session", "会话归属表(记录每个会话归属的账号和角色)");
executeComment("COLUMN conversation_session.conversation_id", "会话 ID(主键)");
executeComment("COLUMN conversation_session.account_id", "归属账号 ID(关联 customer_account.id)");
executeComment("COLUMN conversation_session.role_id", "归属角色 ID(关联 customer_service_role.id)");
executeComment("COLUMN conversation_session.create_time", "创建时间");
executeComment("COLUMN conversation_session.update_time", "更新时间");
// ===== sensitive_word =====
executeComment("TABLE sensitive_word", "敏感词表(DFA 引擎驱动的内容安全过滤)");
executeComment("COLUMN sensitive_word.id", "主键(雪花算法生成)");
executeComment("COLUMN sensitive_word.word", "敏感词内容");
executeComment("COLUMN sensitive_word.category", "分类: politics / porn / abuse / custom,默认 custom");
executeComment("COLUMN sensitive_word.level", "级别: 1=脱敏(MASK) / 2=拦截(BLOCK)");
executeComment("COLUMN sensitive_word.is_active", "是否启用");
executeComment("COLUMN sensitive_word.remark", "备注说明");
executeComment("COLUMN sensitive_word.create_time", "创建时间");
executeComment("COLUMN sensitive_word.update_time", "更新时间");
executeComment("COLUMN sensitive_word.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
// ===== content_audit_log =====
executeComment("TABLE content_audit_log", "内容审计日志表(记录敏感词命中事件,只追加不删除)");
executeComment("COLUMN content_audit_log.id", "主键(雪花算法生成)");
executeComment("COLUMN content_audit_log.session_id", "会话 ID(关联 conversation_session)");
executeComment("COLUMN content_audit_log.direction", "检测方向: INPUT(用户输入) / OUTPUT(AI 输出)");
executeComment("COLUMN content_audit_log.original_text", "原始违规内容(截断至前 50 字)");
executeComment("COLUMN content_audit_log.hit_words", "命中词列表(JSON 数组)");
executeComment("COLUMN content_audit_log.action_taken", "采取的动作: PASS(放行) / MASK(脱敏) / BLOCK(拦截)");
executeComment("COLUMN content_audit_log.create_time", "创建时间(事件发生时间)");
// ===== message_feedback =====
executeComment("TABLE message_feedback", "消息反馈表(用户对 AI 回复的点赞/点踩反馈)");
executeComment("COLUMN message_feedback.id", "主键(雪花算法生成)");
executeComment("COLUMN message_feedback.message_id", "AI 消息 ID(唯一索引,重复提交覆盖)");
executeComment("COLUMN message_feedback.conversation_id", "会话 ID");
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.create_time", "创建时间");
executeComment("COLUMN message_feedback.update_time", "更新时间(重复提交时覆盖)");
executeComment("COLUMN message_feedback.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
// ===== knowledge_faq =====
executeComment("TABLE knowledge_faq", "FAQ 知识库表(用于意图路由后的精准问答匹配)");
executeComment("COLUMN knowledge_faq.id", "主键(雪花算法生成)");
executeComment("COLUMN knowledge_faq.question", "标准问题");
executeComment("COLUMN knowledge_faq.answer", "标准答案");
executeComment("COLUMN knowledge_faq.similar_questions", "相似问题列表(JSON 字符串数组)");
executeComment("COLUMN knowledge_faq.category", "FAQ 分类");
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.create_time", "创建时间");
executeComment("COLUMN knowledge_faq.update_time", "更新时间");
executeComment("COLUMN knowledge_faq.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
// ===== faq_embedding =====
executeComment("TABLE faq_embedding", "FAQ 向量索引表(存储 FAQ 语义向量,用于相似问题匹配)");
executeComment("COLUMN faq_embedding.id", "主键");
executeComment("COLUMN faq_embedding.faq_id", "关联 FAQ ID(对应 knowledge_faq.id)");
executeComment("COLUMN faq_embedding.embedding", "向量嵌入(维度由 knowledge.vector.dimension 配置)");
executeComment("COLUMN faq_embedding.model_name", "使用的 Embedding 模型名称");
executeComment("COLUMN faq_embedding.create_time", "创建时间");
log.info("数据库表注释已应用");
} catch (Exception e) {
log.warn("应用数据库表注释时出错", e);
}
}
/**
* 执行单条 COMMENT ON 语句
*/
private void executeComment(String target, String comment) {
jdbcTemplate.execute(String.format("COMMENT ON %s IS '%s'", target, comment.replace("'", "''")));
}
}

48
src/main/java/com/wok/supportbot/controller/AiController.java

@ -8,6 +8,7 @@ import com.wok.supportbot.service.CustomerAccountService.AccountScope;
import com.wok.supportbot.service.CustomerServiceRoleService;
import com.wok.supportbot.service.CustomerServiceRoleService.RoleScope;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
@ -29,6 +30,7 @@ import java.util.Map;
@RestController
@Slf4j
@RequestMapping("/ai")
public class AiController {
@ -146,9 +148,14 @@ public class AiController {
if (isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) {
return assistantApp.doChat(message, chatId, sys);
}
return assistantApp.doChatWithRagStrategy(
message, chatId, normalizeStrategy(rewriteStrategy),
resolveCategoryIds(scope, categoryId, categoryIds), sys);
try {
return assistantApp.doChatWithRagStrategy(
message, chatId, normalizeStrategy(rewriteStrategy),
resolveCategoryIds(scope, categoryId, categoryIds), sys);
} catch (Exception e) {
log.error("RAG 对话失败 [strategy={}, chatId={}]: {}", rewriteStrategy, chatId, e.getMessage(), e);
return "抱歉,知识库检索出现异常,请稍后重试。";
}
}
/**
@ -190,22 +197,27 @@ public class AiController {
if (message == null || message.isBlank() || isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) {
return Map.of("success", true, "data", List.of());
}
List<Long> cats = resolveCategoryIds(scope, categoryId, categoryIds);
List<Document> docs = assistantApp.retrieveRagSources(message, chatId, normalizeStrategy(rewriteStrategy), cats);
List<Map<String, Object>> out = new ArrayList<>();
for (Document doc : docs) {
Map<String, Object> meta = doc.getMetadata();
Map<String, Object> item = new LinkedHashMap<>();
item.put("documentId", meta.get("documentId"));
item.put("title", meta.get("title"));
item.put("sourceName", meta.get("sourceName"));
item.put("chunkIndex", meta.get("chunkIndex"));
item.put("score", meta.get("distance"));
String text = doc.getText();
item.put("snippet", text != null && text.length() > 160 ? text.substring(0, 160) + "…" : text);
out.add(item);
try {
List<Long> cats = resolveCategoryIds(scope, categoryId, categoryIds);
List<Document> docs = assistantApp.retrieveRagSources(message, chatId, normalizeStrategy(rewriteStrategy), cats);
List<Map<String, Object>> out = new ArrayList<>();
for (Document doc : docs) {
Map<String, Object> meta = doc.getMetadata();
Map<String, Object> item = new LinkedHashMap<>();
item.put("documentId", meta.get("documentId"));
item.put("title", meta.get("title"));
item.put("sourceName", meta.get("sourceName"));
item.put("chunkIndex", meta.get("chunkIndex"));
item.put("score", meta.get("distance"));
String text = doc.getText();
item.put("snippet", text != null && text.length() > 160 ? text.substring(0, 160) + "…" : text);
out.add(item);
}
return Map.of("success", true, "data", out);
} catch (Exception e) {
log.error("获取 RAG 引用来源失败 [strategy={}]: {}", rewriteStrategy, e.getMessage(), e);
return Map.of("success", true, "data", List.of());
}
return Map.of("success", true, "data", out);
}
/**

8
src/main/java/com/wok/supportbot/controller/DocumentController.java

@ -468,8 +468,14 @@ public class DocumentController {
if (categoryIds.isEmpty() && categoryId != null) {
categoryIds = List.of(categoryId);
}
String searchMode = (String) body.get("searchMode");
List<SearchResult> results = documentService.searchDocuments(query, topK, similarityThreshold, categoryIds);
List<SearchResult> results;
if (searchMode != null && !searchMode.isEmpty()) {
results = documentService.searchDocuments(query, topK, similarityThreshold, categoryIds, searchMode);
} else {
results = documentService.searchDocuments(query, topK, similarityThreshold, categoryIds);
}
return ResponseEntity.ok(Map.of(
"success", true,
"data", results,

277
src/main/java/com/wok/supportbot/controller/FaqController.java

@ -0,0 +1,277 @@
package com.wok.supportbot.controller;
import com.wok.supportbot.entity.KnowledgeFaq;
import com.wok.supportbot.service.FaqService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* FAQ 知识库管理控制器
* 提供 FAQ CRUD批量导入/导出匹配统计等功能
*/
@RestController
@RequestMapping("/faq")
@Slf4j
public class FaqController {
@Autowired
private FaqService faqService;
// ==================== 分页列表 ====================
/**
* 分页查询 FAQ 列表
*/
@GetMapping("/list")
public ResponseEntity<Map<String, Object>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String category,
@RequestParam(required = false) String status) {
try {
Map<String, Object> data = faqService.list(page, size, keyword, category, status);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "查询成功",
"data", data
));
} catch (Exception e) {
log.error("FAQ 列表查询失败", e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "查询失败:" + e.getMessage()
));
}
}
// ==================== 新增 ====================
/**
* 新增 FAQ
*/
@PostMapping
public ResponseEntity<Map<String, Object>> create(@RequestBody KnowledgeFaq faq) {
try {
if (faq.getQuestion() == null || faq.getQuestion().isBlank()) {
return ResponseEntity.status(400).body(Map.of(
"success", false,
"message", "问题内容不能为空"
));
}
if (faq.getAnswer() == null || faq.getAnswer().isBlank()) {
return ResponseEntity.status(400).body(Map.of(
"success", false,
"message", "答案内容不能为空"
));
}
KnowledgeFaq created = faqService.create(faq);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "创建成功",
"data", created
));
} catch (Exception e) {
log.error("FAQ 创建失败", e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "创建失败:" + e.getMessage()
));
}
}
// ==================== 修改 ====================
/**
* 修改 FAQ
*/
@PutMapping("/{id}")
public ResponseEntity<Map<String, Object>> update(@PathVariable Long id, @RequestBody KnowledgeFaq faq) {
try {
KnowledgeFaq updated = faqService.update(id, faq);
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("FAQ 更新失败: id={}", id, e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "更新失败:" + e.getMessage()
));
}
}
// ==================== 删除 ====================
/**
* 删除 FAQ逻辑删除
*/
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
try {
faqService.delete(id);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "删除成功"
));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(Map.of(
"success", false,
"message", e.getMessage()
));
} catch (Exception e) {
log.error("FAQ 删除失败: id={}", id, e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "删除失败:" + e.getMessage()
));
}
}
// ==================== 启用/禁用 ====================
/**
* 切换 FAQ 启用/禁用状态
*/
@PutMapping("/{id}/toggle")
public ResponseEntity<Map<String, Object>> toggleStatus(
@PathVariable Long id,
@RequestParam String status) {
try {
faqService.toggleStatus(id, status);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "状态切换成功"
));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(Map.of(
"success", false,
"message", e.getMessage()
));
} catch (Exception e) {
log.error("FAQ 状态切换失败: id={}", id, e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "状态切换失败:" + e.getMessage()
));
}
}
// ==================== 批量导入 ====================
/**
* 批量导入 FAQ
* 请求体格式: {"faqs": [{"question":"...", "answer":"...", "similarQuestions":"[...]", "category":"...", "priority":0}]}
*/
@PostMapping("/batch-import")
public ResponseEntity<Map<String, Object>> batchImport(@RequestBody Map<String, List<KnowledgeFaq>> body) {
try {
List<KnowledgeFaq> faqs = body.get("faqs");
if (faqs == null || faqs.isEmpty()) {
return ResponseEntity.status(400).body(Map.of(
"success", false,
"message", "导入数据不能为空"
));
}
int count = faqService.batchImport(faqs);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "批量导入成功,共导入 " + count + " 条",
"data", Map.of("importedCount", count)
));
} catch (Exception e) {
log.error("FAQ 批量导入失败", e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "批量导入失败:" + e.getMessage()
));
}
}
// ==================== 导出 ====================
/**
* 导出所有启用的 FAQ返回 JSON 列表后续可扩展为 Excel
*/
@GetMapping("/export")
public ResponseEntity<Map<String, Object>> exportAll() {
try {
List<KnowledgeFaq> faqs = faqService.exportAll();
return ResponseEntity.ok(Map.of(
"success", true,
"message", "导出成功",
"data", faqs
));
} catch (Exception e) {
log.error("FAQ 导出失败", e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "导出失败:" + e.getMessage()
));
}
}
// ==================== 统计 ====================
/**
* 获取 FAQ 匹配统计信息
*/
@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> getStats() {
try {
Map<String, Object> stats = faqService.getStats();
return ResponseEntity.ok(Map.of(
"success", true,
"message", "查询成功",
"data", stats
));
} catch (Exception e) {
log.error("FAQ 统计查询失败", e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "统计查询失败:" + e.getMessage()
));
}
}
// ==================== 手动重算向量 ====================
/**
* 手动重新计算某条 FAQ 的向量嵌入
*/
@PostMapping("/{id}/recompute-embedding")
public ResponseEntity<Map<String, Object>> recomputeEmbedding(@PathVariable Long id) {
try {
faqService.recomputeEmbedding(id);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "向量重新计算完成"
));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(Map.of(
"success", false,
"message", e.getMessage()
));
} catch (Exception e) {
log.error("FAQ 向量重算失败: id={}", id, e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "向量重算失败:" + e.getMessage()
));
}
}
}

146
src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java

@ -0,0 +1,146 @@
package com.wok.supportbot.controller;
import com.wok.supportbot.entity.MessageFeedback;
import com.wok.supportbot.service.MessageFeedbackService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 消息反馈控制器
* 提供反馈提交统计查询等 API
*/
@RestController
@Slf4j
public class MessageFeedbackController {
@Autowired
private MessageFeedbackService messageFeedbackService;
/**
* 提交/修改反馈upsert
*
* @param feedback 反馈信息messageId, conversationId, feedbackType, reasonCategory, reasonComment
* @return 操作结果
*/
@PostMapping("/feedback")
public ResponseEntity<Map<String, Object>> submitFeedback(@RequestBody MessageFeedback feedback) {
try {
// messageId 缺失时自动生成保证 upsert 索引不冲突
if (feedback.getMessageId() == null || feedback.getMessageId().isBlank()) {
feedback.setMessageId("auto_" + UUID.randomUUID().toString().replace("-", ""));
log.warn("反馈请求缺少 messageId,已自动生成: {}", feedback.getMessageId());
}
if (feedback.getFeedbackType() == null || feedback.getFeedbackType().isBlank()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "feedbackType 不能为空"
));
}
String type = feedback.getFeedbackType().toUpperCase();
if (!type.equals("THUMBS_UP") && !type.equals("THUMBS_DOWN")) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "feedbackType 必须为 THUMBS_UP 或 THUMBS_DOWN"
));
}
feedback.setFeedbackType(type);
MessageFeedback saved = messageFeedbackService.submitFeedback(feedback);
return ResponseEntity.ok(Map.of(
"success", true,
"data", saved,
"message", "反馈提交成功"
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "反馈提交失败:" + e.getMessage()
));
}
}
/**
* 获取反馈统计数据
*
* @param startDate 开始日期yyyy-MM-dd可选
* @param endDate 结束日期yyyy-MM-dd可选
* @return 统计结果
*/
@GetMapping("/feedback/stats")
public ResponseEntity<Map<String, Object>> getStats(
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate) {
try {
Map<String, Object> stats = messageFeedbackService.getStats(startDate, endDate);
return ResponseEntity.ok(Map.of(
"success", true,
"data", stats
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "查询统计失败:" + e.getMessage()
));
}
}
/**
* 按会话ID查询反馈列表
*
* @param conversationId 会话ID
* @return 反馈列表
*/
@GetMapping("/feedback/by-conversation/{conversationId}")
public ResponseEntity<Map<String, Object>> getByConversation(
@PathVariable String conversationId) {
try {
List<MessageFeedback> feedbacks = messageFeedbackService.getByConversationId(conversationId);
return ResponseEntity.ok(Map.of(
"success", true,
"data", feedbacks
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "查询失败:" + e.getMessage()
));
}
}
/**
* 批量查询反馈状态 SDK 回显使用
*
* @param messageIds 逗号分隔的消息ID列表
* @return 反馈列表
*/
@GetMapping("/feedback/batch")
public ResponseEntity<Map<String, Object>> getBatchByMessageIds(
@RequestParam String messageIds) {
try {
if (messageIds == null || messageIds.isBlank()) {
return ResponseEntity.ok(Map.of(
"success", true,
"data", List.of()
));
}
List<String> idList = Arrays.asList(messageIds.split(","));
List<MessageFeedback> feedbacks = messageFeedbackService.getBatchByMessageIds(idList);
return ResponseEntity.ok(Map.of(
"success", true,
"data", feedbacks
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "批量查询失败:" + e.getMessage()
));
}
}
}

239
src/main/java/com/wok/supportbot/controller/SensitiveWordController.java

@ -0,0 +1,239 @@
package com.wok.supportbot.controller;
import com.wok.supportbot.entity.SensitiveWord;
import com.wok.supportbot.service.SensitiveWordService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 敏感词管理 & 审计日志查询接口
*/
@Slf4j
@RestController
@RequestMapping("/sensitive-word")
public class SensitiveWordController {
@Autowired
private SensitiveWordService sensitiveWordService;
@Autowired
private JdbcTemplate jdbcTemplate;
// ==================== 敏感词 CRUD ====================
/**
* 分页查询敏感词列表
*/
@GetMapping("/list")
public ResponseEntity<Map<String, Object>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String category) {
try {
Map<String, Object> result = sensitiveWordService.list(page, size, keyword, category);
Map<String, Object> data = new HashMap<>();
data.put("success", true);
data.put("data", result.get("records"));
data.put("total", result.get("total"));
data.put("page", result.get("page"));
data.put("size", result.get("size"));
data.put("pages", result.get("pages"));
return ResponseEntity.ok(data);
} catch (Exception e) {
log.error("查询敏感词列表失败", e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "查询失败:" + e.getMessage()
));
}
}
/**
* 新增敏感词
*/
@PostMapping
public ResponseEntity<Map<String, Object>> create(@RequestBody SensitiveWord word) {
try {
SensitiveWord created = sensitiveWordService.create(word);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "新增成功",
"data", created
));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().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()
));
}
}
/**
* 修改敏感词
*/
@PutMapping("/{id}")
public ResponseEntity<Map<String, Object>> update(@PathVariable Long id, @RequestBody SensitiveWord word) {
try {
SensitiveWord updated = sensitiveWordService.update(id, word);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "修改成功",
"data", updated
));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().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()
));
}
}
/**
* 删除敏感词逻辑删除
*/
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
try {
sensitiveWordService.delete(id);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "删除成功"
));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().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()
));
}
}
/**
* 批量导入敏感词
* <p>
* 请求体格式{ "words": ["词1", "词2"], "category": "custom", "level": 1 }
*/
@SuppressWarnings("unchecked")
@PostMapping("/batch-import")
public ResponseEntity<Map<String, Object>> batchImport(@RequestBody Map<String, Object> body) {
try {
List<String> words = (List<String>) body.get("words");
String category = (String) body.getOrDefault("category", "custom");
int level = body.containsKey("level") ? ((Number) body.get("level")).intValue() : 1;
if (words == null || words.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "words 列表不能为空"
));
}
int imported = sensitiveWordService.batchImport(words, category, level);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "批量导入完成,成功 " + imported + " 个",
"data", Map.of("imported", imported, "total", words.size())
));
} catch (Exception e) {
log.error("批量导入敏感词失败", e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "批量导入失败:" + e.getMessage()
));
}
}
/**
* Map key snake_case 转为 camelCase
*/
private Map<String, Object> snakeToCamelKeys(Map<String, Object> map) {
Map<String, Object> result = new HashMap<>();
map.forEach((key, value) -> {
String camelKey = key;
int idx;
while ((idx = camelKey.indexOf('_')) != -1 && idx + 1 < camelKey.length()) {
camelKey = camelKey.substring(0, idx) + Character.toUpperCase(camelKey.charAt(idx + 1)) + camelKey.substring(idx + 2);
}
result.put(camelKey, value);
});
return result;
}
// ==================== 审计日志查询 ====================
/**
* 分页查询内容审计日志使用 JdbcTemplate 直接查询因为审计日志表无逻辑删除字段
*/
@GetMapping("/audit-log")
public ResponseEntity<Map<String, Object>> auditLog(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String sessionId) {
try {
// 构建 WHERE 子句
StringBuilder whereClause = new StringBuilder("WHERE 1=1");
List<Object> params = new java.util.ArrayList<>();
if (sessionId != null && !sessionId.isBlank()) {
whereClause.append(" AND session_id = ?");
params.add(sessionId);
}
// 查询总数
String countSql = "SELECT COUNT(*) FROM content_audit_log " + whereClause;
Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray());
// 查询列表
String listSql = "SELECT * FROM content_audit_log " + whereClause +
" ORDER BY create_time DESC LIMIT ? OFFSET ?";
List<Object> queryParams = new java.util.ArrayList<>(params);
queryParams.add(size);
queryParams.add((long) (page - 1) * size);
List<Map<String, Object>> rawRecords = jdbcTemplate.queryForList(listSql, queryParams.toArray());
// JdbcTemplate 返回数据库列名snake_case需转换为 camelCase 供前端使用
List<Map<String, Object>> records = rawRecords.stream()
.map(this::snakeToCamelKeys)
.toList();
Map<String, Object> data = new HashMap<>();
data.put("success", true);
data.put("data", records);
data.put("total", total);
data.put("page", page);
data.put("size", size);
data.put("pages", total != null ? (total + size - 1) / size : 0);
return ResponseEntity.ok(data);
} catch (Exception e) {
log.error("查询审计日志失败", e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "查询失败:" + e.getMessage()
));
}
}
}

12
src/main/java/com/wok/supportbot/dao/ContentAuditLogMapper.java

@ -0,0 +1,12 @@
package com.wok.supportbot.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wok.supportbot.entity.ContentAuditLog;
import org.apache.ibatis.annotations.Mapper;
/**
* 内容审计日志 Mapper
*/
@Mapper
public interface ContentAuditLogMapper extends BaseMapper<ContentAuditLog> {
}

12
src/main/java/com/wok/supportbot/dao/KnowledgeFaqMapper.java

@ -0,0 +1,12 @@
package com.wok.supportbot.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wok.supportbot.entity.KnowledgeFaq;
import org.apache.ibatis.annotations.Mapper;
/**
* FAQ 知识库 Mapper
*/
@Mapper
public interface KnowledgeFaqMapper extends BaseMapper<KnowledgeFaq> {
}

12
src/main/java/com/wok/supportbot/dao/MessageFeedbackMapper.java

@ -0,0 +1,12 @@
package com.wok.supportbot.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wok.supportbot.entity.MessageFeedback;
import org.apache.ibatis.annotations.Mapper;
/**
* 消息反馈 Mapper
*/
@Mapper
public interface MessageFeedbackMapper extends BaseMapper<MessageFeedback> {
}

12
src/main/java/com/wok/supportbot/dao/SensitiveWordMapper.java

@ -0,0 +1,12 @@
package com.wok.supportbot.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wok.supportbot.entity.SensitiveWord;
import org.apache.ibatis.annotations.Mapper;
/**
* 敏感词 Mapper
*/
@Mapper
public interface SensitiveWordMapper extends BaseMapper<SensitiveWord> {
}

59
src/main/java/com/wok/supportbot/entity/ContentAuditLog.java

@ -0,0 +1,59 @@
package com.wok.supportbot.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.wok.supportbot.handler.PostgresJsonTypeHandler;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.Map;
/**
* 内容审计日志实体不可删除无逻辑删除字段
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "content_audit_log", autoResultMap = true)
public class ContentAuditLog 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("session_id")
private String sessionId;
/** 方向:INPUT/OUTPUT */
@TableField("direction")
private String direction;
/** 违规内容截断(最多50字) */
@TableField("original_text")
private String originalText;
/** 命中词列表(JSON格式) */
@TableField(value = "hit_words", typeHandler = PostgresJsonTypeHandler.class)
private Map<String, Object> hitWords;
/** 采取的动作:PASS/MASK/BLOCK */
@TableField("action_taken")
private String actionTaken;
/** 创建时间 */
@TableField(value = "create_time", fill = FieldFill.INSERT)
private Date createTime;
}

102
src/main/java/com/wok/supportbot/entity/KnowledgeFaq.java

@ -0,0 +1,102 @@
package com.wok.supportbot.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
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 知识库实体
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName("knowledge_faq")
public class KnowledgeFaq implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键 ID雪花算法
*/
@TableId(value = "id", type = IdType.ASSIGN_ID)
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
/**
* FAQ 问题
*/
@TableField("question")
private String question;
/**
* 标准答案
*/
@TableField("answer")
private String answer;
/**
* 相似问题列表存储为 JSON 字符串如 '["问题1","问题2"]'
* 不使用 PostgresJsonTypeHandler Service 层手动解析
*/
@TableField("similar_questions")
private String similarQuestions;
/**
* FAQ 分类"退货政策"
*/
@TableField("category")
private String category;
/**
* 状态: ENABLED / DISABLED
*/
@TableField("status")
private String status;
/**
* 优先级数值越大越优先
*/
@TableField("priority")
private Integer priority;
/**
* 命中次数统计
*/
@TableField("hit_count")
private Long hitCount;
/**
* 来源: manual / import
*/
@TableField("source")
private String source;
/**
* 创建时间
*/
@TableField("create_time")
private Date createTime;
/**
* 更新时间
*/
@TableField("update_time")
private Date updateTime;
/**
* 逻辑删除标记
*/
@TableLogic
@TableField("is_delete")
private boolean isDelete;
}

86
src/main/java/com/wok/supportbot/entity/MessageFeedback.java

@ -0,0 +1,86 @@
package com.wok.supportbot.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* 消息反馈实体
* 记录用户对 AI 回复的点赞/点踩反馈
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName("message_feedback")
public class MessageFeedback 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;
/**
* AI消息ID前端 msgId UUID
*/
@TableField("message_id")
private String messageId;
/**
* 会话ID
*/
@TableField("conversation_id")
private String conversationId;
/**
* 反馈类型: THUMBS_UP / THUMBS_DOWN
*/
@TableField("feedback_type")
private String feedbackType;
/**
* 点踩原因分类: inaccurate / irrelevant / incomplete / other
* THUMBS_DOWN 时填写
*/
@TableField("reason_category")
private String reasonCategory;
/**
* 自由文本补充说明可选
*/
@TableField("reason_comment")
private String reasonComment;
/**
* 创建时间
*/
@TableField(value = "create_time", fill = FieldFill.INSERT)
private Date createTime;
/**
* 更新时间
*/
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
/**
* 是否删除 false-未删除 true-已删除
*/
@TableField("is_delete")
@TableLogic
private boolean isDelete;
}

5
src/main/java/com/wok/supportbot/entity/SearchResult.java

@ -55,6 +55,11 @@ public class SearchResult implements Serializable {
*/
private String documentId;
/**
* 检索模式标识VECTOR / KEYWORD / HYBRID
*/
private String searchMode;
/**
* 原始元数据
*/

66
src/main/java/com/wok/supportbot/entity/SensitiveWord.java

@ -0,0 +1,66 @@
package com.wok.supportbot.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* 敏感词实体
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName("sensitive_word")
public class SensitiveWord 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;
/** 敏感词内容 */
@TableField("word")
private String word;
/** 分类:politics/porn/abuse/custom */
@TableField("category")
private String category;
/** 级别:1=警告,2=拦截 */
@TableField("level")
private Integer level;
/** 是否启用 */
@TableField("is_active")
private Boolean isActive;
/** 备注 */
@TableField("remark")
private String remark;
/** 创建时间 */
@TableField(value = "create_time", fill = FieldFill.INSERT)
private Date createTime;
/** 更新时间 */
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
/** 逻辑删除标识 */
@TableField("is_delete")
@TableLogic
private boolean isDelete;
}

283
src/main/java/com/wok/supportbot/rag/HybridSearchService.java

@ -0,0 +1,283 @@
package com.wok.supportbot.rag;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.entity.SearchResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import jakarta.annotation.Resource;
import java.util.*;
import java.util.stream.Collectors;
/**
* 混合检索核心服务
* 支持三种检索模式纯向量检索全文关键词检索混合检索双路 + RRF 融合 + Reranker 精排
*/
@Service
@Slf4j
public class HybridSearchService {
@Resource(name = "pgVectorVectorStore")
private VectorStore pgVectorVectorStore;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private RrfFusion rrfFusion;
@Autowired
private RerankerService rerankerService;
/** JSON 解析器 */
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/**
* 执行检索
*
* @param query 用户查询文本
* @param mode 检索模式VECTOR / KEYWORD / HYBRID
* @param topK 返回结果数量上限
* @param similarityThreshold 向量相似度阈值 VECTOR HYBRID 模式生效
* @param categoryIds 分类ID过滤列表可选
* @return 检索结果列表
*/
public List<SearchResult> search(String query, SearchMode mode, int topK,
double similarityThreshold, List<Long> categoryIds) {
log.info("执行检索: mode={}, topK={}, threshold={}, categoryIds={}", mode, topK, similarityThreshold, categoryIds);
return switch (mode) {
case VECTOR -> vectorSearch(query, topK, similarityThreshold, categoryIds);
case KEYWORD -> keywordSearch(query, topK, categoryIds);
case HYBRID -> hybridSearch(query, topK, similarityThreshold, categoryIds);
};
}
// ==================== 向量检索 ====================
/**
* 纯向量语义检索
* 复用 PgVectorStore similaritySearch支持分类过滤和相似度阈值
*/
private List<SearchResult> vectorSearch(String query, int topK, double threshold, List<Long> categoryIds) {
SearchRequest.Builder builder = SearchRequest.builder()
.query(query)
.topK(topK)
.similarityThreshold(threshold);
// 分类过滤
if (categoryIds != null && !categoryIds.isEmpty()) {
FilterExpressionBuilder fb = new FilterExpressionBuilder();
List<Object> values = categoryIds.stream()
.map(id -> (Object) String.valueOf(id))
.toList();
builder.filterExpression(fb.in("categoryId", values).build());
}
List<Document> docs = pgVectorVectorStore.similaritySearch(builder.build());
log.debug("向量检索完成: 查询='{}', 返回 {} 条", query, docs.size());
return toSearchResults(docs, "VECTOR");
}
// ==================== 关键词检索 ====================
/**
* 全文关键词检索
* 使用 PostgreSQL tsvector 全文索引 ts_rank 排序
*/
private List<SearchResult> keywordSearch(String query, int topK, List<Long> categoryIds) {
String categoryFilter = buildCategoryFilter(categoryIds);
String sql = """
SELECT id, content, metadata,
ts_rank(content_tsvector, plainto_tsquery('simple', ?)) AS rank
FROM vector_store
WHERE content_tsvector @@ plainto_tsquery('simple', ?)
AND is_delete = false
%s
ORDER BY rank DESC
LIMIT ?
""".formatted(categoryFilter);
List<SearchResult> results = jdbcTemplate.query(sql, (rs, rowNum) -> {
SearchResult result = new SearchResult();
result.setId(rs.getString("id"));
result.setContent(rs.getString("content"));
result.setScore(rs.getDouble("rank"));
result.setSearchMode("KEYWORD");
// 解析 metadata JSONB
String metadataStr = rs.getString("metadata");
if (metadataStr != null) {
Map<String, Object> metadata = parseJsonb(metadataStr);
result.setMetadata(metadata);
result.setSourceName(getStringFromMetadata(metadata, "sourceName"));
result.setTitle(getStringFromMetadata(metadata, "title"));
result.setDocumentId(getStringFromMetadata(metadata, "documentId"));
result.setChunkIndex(getIntegerFromMetadata(metadata, "chunkIndex"));
}
return result;
}, query, query, topK);
log.debug("关键词检索完成: 查询='{}', 返回 {} 条", query, results.size());
return results;
}
// ==================== 混合检索 ====================
/**
* 混合检索双路检索 RRF 融合 Reranker 精排
* 向量和关键词各取 topK*2 作为候选池 RRF 更多融合素材
*/
private List<SearchResult> hybridSearch(String query, int topK, double threshold, List<Long> categoryIds) {
// 双路检索各取 topK*2 RRF 更多候选
int candidateSize = topK * 2;
List<SearchResult> vectorResults = vectorSearch(query, candidateSize, threshold, categoryIds);
List<SearchResult> keywordResults = keywordSearch(query, candidateSize, categoryIds);
log.debug("混合检索双路完成: 向量 {} 条, 关键词 {} 条", vectorResults.size(), keywordResults.size());
// 转换为 ScoredDocument 列表
List<RrfFusion.ScoredDocument> vectorDocs = toScoredDocs(vectorResults);
List<RrfFusion.ScoredDocument> keywordDocs = toScoredDocs(keywordResults);
// RRF 融合
List<RrfFusion.ScoredDocument> fused = rrfFusion.fuse(List.of(vectorDocs, keywordDocs));
// Reranker 精排如有 RERANK 模型配置则调用 API否则 fallback RRF 原始排序
List<RrfFusion.ScoredDocument> reranked = rerankerService.rerank(query, fused, topK);
return scoredDocsToSearchResults(reranked, "HYBRID");
}
// ==================== 转换方法 ====================
/**
* Spring AI Document 列表转换为 SearchResult 列表
*/
private List<SearchResult> toSearchResults(List<Document> docs, String searchMode) {
return docs.stream().map(doc -> {
SearchResult result = new SearchResult();
result.setId(doc.getId());
result.setContent(doc.getText());
result.setScore(doc.getScore() != null ? doc.getScore() : 0.0);
result.setSearchMode(searchMode);
Map<String, Object> metadata = doc.getMetadata();
if (metadata != null) {
result.setMetadata(metadata);
result.setSourceName(getStringFromMetadata(metadata, "sourceName"));
result.setTitle(getStringFromMetadata(metadata, "title"));
result.setDocumentId(getStringFromMetadata(metadata, "documentId"));
result.setChunkIndex(getIntegerFromMetadata(metadata, "chunkIndex"));
}
return result;
}).collect(Collectors.toList());
}
/**
* RRF/Reranker 产出的 ScoredDocument 列表转换为 SearchResult 列表
*/
private List<SearchResult> scoredDocsToSearchResults(List<RrfFusion.ScoredDocument> scoredDocs, String searchMode) {
return scoredDocs.stream().map(doc -> {
SearchResult result = new SearchResult();
result.setId(doc.getId());
result.setContent(doc.getContent());
result.setScore(doc.getScore());
result.setSearchMode(searchMode);
Map<String, Object> metadata = doc.getMetadata();
if (metadata != null) {
result.setMetadata(metadata);
result.setSourceName(getStringFromMetadata(metadata, "sourceName"));
result.setTitle(getStringFromMetadata(metadata, "title"));
result.setDocumentId(getStringFromMetadata(metadata, "documentId"));
result.setChunkIndex(getIntegerFromMetadata(metadata, "chunkIndex"));
}
return result;
}).collect(Collectors.toList());
}
/**
* SearchResult 列表转换为 RRF 用的 ScoredDocument 列表
*/
private List<RrfFusion.ScoredDocument> toScoredDocs(List<SearchResult> results) {
return results.stream().map(r -> {
Map<String, Object> metadata = null;
if (r.getMetadata() instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) r.getMetadata();
metadata = map;
}
return new RrfFusion.ScoredDocument(
r.getId(),
r.getContent(),
metadata,
r.getScore() != null ? r.getScore() : 0.0
);
}).collect(Collectors.toList());
}
// ==================== 辅助方法 ====================
/**
* 构建分类过滤 SQL WHERE 子句
* metadata JSONB 中提取 categoryId 字段进行匹配
*
* @param categoryIds 分类ID列表
* @return SQL 片段 "AND metadata->>'categoryId' IN ('1','2','3')"空列表时返回空字符串
*/
private String buildCategoryFilter(List<Long> categoryIds) {
if (categoryIds == null || categoryIds.isEmpty()) {
return "";
}
String inValues = categoryIds.stream()
.map(id -> "'" + id + "'")
.collect(Collectors.joining(","));
return "AND metadata->>'categoryId' IN (" + inValues + ")";
}
/**
* 解析 JSONB 字符串为 Map
*/
private Map<String, Object> parseJsonb(String json) {
try {
return OBJECT_MAPPER.readValue(json, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
log.warn("解析 metadata JSONB 失败: {}", e.getMessage());
return Collections.emptyMap();
}
}
/**
* metadata Map 中安全获取 String
*/
private String getStringFromMetadata(Map<String, Object> metadata, String key) {
Object value = metadata.get(key);
return value != null ? value.toString() : null;
}
/**
* metadata Map 中安全获取 Integer
*/
private Integer getIntegerFromMetadata(Map<String, Object> metadata, String key) {
Object value = metadata.get(key);
if (value instanceof Number) {
return ((Number) value).intValue();
}
if (value instanceof String) {
try {
return Integer.parseInt((String) value);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
}

236
src/main/java/com/wok/supportbot/rag/RerankerService.java

@ -0,0 +1,236 @@
package com.wok.supportbot.rag;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.wok.supportbot.dao.AiModelConfigMapper;
import com.wok.supportbot.entity.AiModelConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import java.util.*;
import java.util.stream.Collectors;
/**
* 重排序服务
* 查询 ai_model_config 表中 RERANK 类型的活跃配置调用对应的 Rerank API 对候选文档精排
* 支持 DashScope OpenAI 兼容两种协议无配置或调用异常时 fallback 到原始排序
*/
@Service
@Slf4j
public class RerankerService {
@Autowired
private AiModelConfigMapper aiModelConfigMapper;
/** HTTP 超时时间(秒) */
private static final int TIMEOUT_SECONDS = 3;
/**
* 对候选文档执行重排序
* - RERANK 配置 调用对应提供商的 Rerank API
* - 无配置 / 超时 / 异常 fallback 返回 candidates topN
*
* @param query 用户查询
* @param candidates RRF 融合后的候选文档列表
* @param topN 最终返回的文档数量
* @return 按相关性降序排列的 topN 条文档
*/
public List<RrfFusion.ScoredDocument> rerank(String query, List<RrfFusion.ScoredDocument> candidates, int topN) {
if (candidates == null || candidates.isEmpty()) {
return Collections.emptyList();
}
// 查询 RERANK 类型的活跃配置
AiModelConfig config = getActiveRerankConfig();
if (config == null) {
log.debug("未找到 RERANK 活跃配置,使用 RRF 原始排序作为 fallback");
return fallback(candidates, topN);
}
try {
List<RrfFusion.ScoredDocument> reranked = doRerank(query, candidates, topN, config);
log.debug("Reranker 精排完成: provider={}, 输入 {} 条, 输出 {} 条",
config.getProvider(), candidates.size(), reranked.size());
return reranked;
} catch (Exception e) {
log.warn("Reranker 调用失败 (provider={}): {}, 使用 RRF 原始排序作为 fallback",
config.getProvider(), e.getMessage());
return fallback(candidates, topN);
}
}
/**
* 获取 RERANK 类型的活跃配置
*/
private AiModelConfig getActiveRerankConfig() {
LambdaQueryWrapper<AiModelConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(AiModelConfig::getAppType, "RERANK")
.eq(AiModelConfig::getIsActive, true)
.last("LIMIT 1");
return aiModelConfigMapper.selectOne(wrapper);
}
/**
* 根据提供商类型分发 Rerank API 调用
*/
private List<RrfFusion.ScoredDocument> doRerank(String query,
List<RrfFusion.ScoredDocument> candidates,
int topN,
AiModelConfig config) {
String provider = config.getProvider().toLowerCase();
if ("dashscope".equals(provider)) {
return dashscopeRerank(query, candidates, topN, config);
} else {
return openaiCompatibleRerank(query, candidates, topN, config);
}
}
/**
* DashScope Rerank API 调用
* POST https://dashscope.aliyuncs.com/api/v1/services/rerank
*/
private List<RrfFusion.ScoredDocument> dashscopeRerank(String query,
List<RrfFusion.ScoredDocument> candidates,
int topN,
AiModelConfig config) {
String url = "https://dashscope.aliyuncs.com/api/v1/services/rerank";
List<String> documents = candidates.stream()
.map(RrfFusion.ScoredDocument::getContent)
.collect(Collectors.toList());
// 构建请求体
Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("top_n", topN);
parameters.put("return_documents", true);
Map<String, Object> input = new LinkedHashMap<>();
input.put("query", query);
input.put("documents", documents);
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", config.getModelName() != null ? config.getModelName() : "gte-rerank");
body.put("input", input);
body.put("parameters", parameters);
// 发送请求
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(config.getApiKey());
ResponseEntity<Map> response = postWithTimeout(url, headers, body);
// 解析响应{"output":{"results":[{"index":0,"relevance_score":0.95},...]}}
Map responseBody = response.getBody();
if (responseBody == null) {
throw new RuntimeException("DashScope Rerank 响应为空");
}
Map output = (Map) responseBody.get("output");
if (output == null) {
throw new RuntimeException("DashScope Rerank 响应缺少 output 字段");
}
List<Map> results = (List<Map>) output.get("results");
if (results == null || results.isEmpty()) {
throw new RuntimeException("DashScope Rerank 结果为空");
}
return mapResultsToDocs(results, candidates);
}
/**
* OpenAI 兼容 Rerank API 调用
* POST {baseUrl}/rerank
*/
private List<RrfFusion.ScoredDocument> openaiCompatibleRerank(String query,
List<RrfFusion.ScoredDocument> candidates,
int topN,
AiModelConfig config) {
String baseUrl = config.getBaseUrl();
if (baseUrl == null || baseUrl.isBlank()) {
throw new IllegalArgumentException(
"OpenAI 兼容 Rerank 提供商 [" + config.getProvider() + "] 未配置 baseUrl");
}
// 确保 URL 拼接正确
String url = baseUrl.endsWith("/") ? baseUrl + "rerank" : baseUrl + "/rerank";
List<String> documents = candidates.stream()
.map(RrfFusion.ScoredDocument::getContent)
.collect(Collectors.toList());
// 构建请求体
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", config.getModelName());
body.put("query", query);
body.put("documents", documents);
body.put("top_n", topN);
// 发送请求
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(config.getApiKey());
ResponseEntity<Map> response = postWithTimeout(url, headers, body);
// 解析响应{"results":[{"index":0,"relevance_score":0.95},...]}
Map responseBody = response.getBody();
if (responseBody == null) {
throw new RuntimeException("OpenAI 兼容 Rerank 响应为空");
}
List<Map> results = (List<Map>) responseBody.get("results");
if (results == null || results.isEmpty()) {
throw new RuntimeException("OpenAI 兼容 Rerank 结果为空");
}
return mapResultsToDocs(results, candidates);
}
/**
* Rerank API 返回的结果映射为 ScoredDocument 列表
*
* @param results API 返回的 results 数组每项含 index relevance_score
* @param candidates 原始候选文档列表用于通过 index 关联文档内容
* @return relevance_score 降序排列的文档列表
*/
private List<RrfFusion.ScoredDocument> mapResultsToDocs(List<Map> results,
List<RrfFusion.ScoredDocument> candidates) {
List<RrfFusion.ScoredDocument> reranked = new ArrayList<>(results.size());
for (Map result : results) {
int index = ((Number) result.get("index")).intValue();
double score = ((Number) result.get("relevance_score")).doubleValue();
if (index >= 0 && index < candidates.size()) {
RrfFusion.ScoredDocument original = candidates.get(index);
reranked.add(new RrfFusion.ScoredDocument(
original.getId(),
original.getContent(),
original.getMetadata(),
score
));
}
}
// relevance_score 降序排列
reranked.sort((a, b) -> Double.compare(b.getScore(), a.getScore()));
return reranked;
}
/**
* 带超时的 HTTP POST 请求
*/
@SuppressWarnings("unchecked")
private ResponseEntity<Map> postWithTimeout(String url, HttpHeaders headers, Map<String, Object> body) {
RestTemplate restTemplate = new RestTemplate();
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
// RestTemplate 默认无超时此处依赖连接/读取超时由底层控制
// Spring Boot 3.x 中可使用 RestClient 替代以获得更好的超时支持
return restTemplate.exchange(url, HttpMethod.POST, entity, Map.class);
}
/**
* Fallback直接截取前 topN 条候选文档
*/
private List<RrfFusion.ScoredDocument> fallback(List<RrfFusion.ScoredDocument> candidates, int topN) {
int limit = Math.min(topN, candidates.size());
return new ArrayList<>(candidates.subList(0, limit));
}
}

88
src/main/java/com/wok/supportbot/rag/RrfFusion.java

@ -0,0 +1,88 @@
package com.wok.supportbot.rag;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.*;
/**
* RRFReciprocal Rank Fusion融合算法
* 将多路检索结果按排名倒数加权合并消除不同检索方式分数尺度不一致的问题
* 公式score(d) = Σ 1.0 / (K + rank_i(d) + 1)其中 K=60 为平滑常数
*/
@Component
@Slf4j
public class RrfFusion {
/** RRF 平滑常数,控制排名靠后的文档权重衰减速度 */
private static final double K = 60.0;
/**
* 对多路排序列表执行 RRF 融合
*
* @param rankedLists 多路检索结果列表每个子列表已按相关性降序排列
* @return RRF 分数降序排列的合并文档列表
*/
public List<ScoredDocument> fuse(List<List<ScoredDocument>> rankedLists) {
// docId 累计 RRF 分数
Map<String, Double> scoreMap = new LinkedHashMap<>();
// docId 文档对象保留首次出现的内容和 metadata
Map<String, ScoredDocument> docMap = new LinkedHashMap<>();
for (List<ScoredDocument> rankedList : rankedLists) {
if (rankedList == null) {
continue;
}
for (int rank = 0; rank < rankedList.size(); rank++) {
ScoredDocument doc = rankedList.get(rank);
if (doc == null || doc.getId() == null) {
continue;
}
double rrfScore = 1.0 / (K + rank + 1);
scoreMap.merge(doc.getId(), rrfScore, Double::sum);
// 保留首次出现的文档内容
docMap.putIfAbsent(doc.getId(), doc);
}
}
// RRF 分数降序排列
List<Map.Entry<String, Double>> sortedEntries = new ArrayList<>(scoreMap.entrySet());
sortedEntries.sort((a, b) -> Double.compare(b.getValue(), a.getValue()));
// 构建结果列表设置 RRF 融合分数
List<ScoredDocument> result = new ArrayList<>(sortedEntries.size());
for (Map.Entry<String, Double> entry : sortedEntries) {
ScoredDocument doc = docMap.get(entry.getKey());
ScoredDocument fusedDoc = new ScoredDocument(
doc.getId(),
doc.getContent(),
doc.getMetadata(),
entry.getValue()
);
result.add(fusedDoc);
}
log.debug("RRF 融合完成: 输入 {} 路, 合并后 {} 条文档", rankedLists.size(), result.size());
return result;
}
/**
* 带分数的文档对象用于 RRF 融合和 Reranker 之间的数据传递
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ScoredDocument {
/** 文档ID */
private String id;
/** 文档内容 */
private String content;
/** 元数据 */
private Map<String, Object> metadata;
/** 相关性分数(RRF 分数或 Reranker 分数) */
private double score;
}
}

19
src/main/java/com/wok/supportbot/rag/SearchMode.java

@ -0,0 +1,19 @@
package com.wok.supportbot.rag;
/**
* 检索模式枚举
* - VECTOR: 纯向量语义检索
* - KEYWORD: 全文关键词检索
* - HYBRID: 混合检索双路检索 + RRF 融合 + Reranker 精排
*/
public enum SearchMode {
/** 纯向量语义检索 */
VECTOR,
/** 混合检索:双路检索 → RRF 融合 → Reranker 精排 */
HYBRID,
/** 全文关键词检索 */
KEYWORD
}

209
src/main/java/com/wok/supportbot/service/ContentSafetyService.java

@ -0,0 +1,209 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.dao.SensitiveWordMapper;
import com.wok.supportbot.entity.SensitiveWord;
import jakarta.annotation.PostConstruct;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.*;
/**
* 内容安全过滤服务 DFA确定有限自动机敏感词匹配引擎
*/
@Slf4j
@Service
public class ContentSafetyService {
@Autowired
private SensitiveWordMapper sensitiveWordMapper;
/** DFA 字典树根节点,使用 volatile + 全新对象替换保证线程安全 */
private volatile DfaNode root = new DfaNode();
// ==================== 内部类定义 ====================
/**
* DFA 节点
*/
static class DfaNode {
Map<Character, DfaNode> children = new HashMap<>();
boolean isEnd = false;
SensitiveWord word;
}
/**
* 命中结果
*/
@Data
@AllArgsConstructor
public static class HitResult {
/** 命中的敏感词 */
private String word;
/** 分类 */
private String category;
/** 级别:1=警告,2=拦截 */
private Integer level;
/** 在原文中的起始位置 */
private int startIndex;
/** 在原文中的结束位置(不含) */
private int endIndex;
}
// ==================== 初始化与重建 ====================
/**
* 应用启动时从数据库加载所有活跃敏感词构建 DFA 字典树
*/
@PostConstruct
public void init() {
rebuild();
}
/**
* 重新从数据库加载并重建字典树增删改敏感词后调用
*/
public synchronized void rebuild() {
try {
QueryWrapper<SensitiveWord> wrapper = new QueryWrapper<>();
wrapper.eq("is_active", true);
List<SensitiveWord> words = sensitiveWordMapper.selectList(wrapper);
// 构建全新的字典树
DfaNode newRoot = new DfaNode();
for (SensitiveWord word : words) {
if (word.getWord() == null || word.getWord().isBlank()) {
continue;
}
addWord(newRoot, word);
}
// 原子替换根节点引用保证线程安全
this.root = newRoot;
log.info("DFA 字典树重建完成,共加载 {} 个敏感词", words.size());
} catch (Exception e) {
log.error("DFA 字典树重建失败", e);
}
}
/**
* 向字典树中添加一个敏感词
*/
private void addWord(DfaNode rootNode, SensitiveWord sensitiveWord) {
String text = sensitiveWord.getWord().toLowerCase();
DfaNode current = rootNode;
for (char c : text.toCharArray()) {
current.children.putIfAbsent(c, new DfaNode());
current = current.children.get(c);
}
current.isEnd = true;
current.word = sensitiveWord;
}
// ==================== 检测与脱敏 ====================
/**
* 检测文本中的敏感词返回所有命中结果
*
* @param text 待检测文本
* @return 命中结果列表
*/
public List<HitResult> detect(String text) {
List<HitResult> hits = new ArrayList<>();
if (text == null || text.isEmpty()) {
return hits;
}
String lowerText = text.toLowerCase();
DfaNode currentNode = root;
for (int i = 0; i < lowerText.length(); i++) {
char c = lowerText.charAt(i);
DfaNode nextNode = currentNode.children.get(c);
if (nextNode != null) {
currentNode = nextNode;
// 如果当前节点是一个完整词的结尾记录命中
if (currentNode.isEnd) {
String hitWord = currentNode.word.getWord();
int startIdx = i - hitWord.length() + 1;
hits.add(new HitResult(
hitWord,
currentNode.word.getCategory(),
currentNode.word.getLevel(),
startIdx,
i + 1
));
}
} else {
// 未匹配到子节点回退到根节点
// 但如果之前已经部分匹配过需要从下一个起始位置重新开始
if (currentNode != root) {
// 回退从上一个匹配起始位置的下一个字符重新开始
// 简化处理直接重置到根节点外层循环继续
currentNode = root;
// 重新检查当前字符是否可以从根节点开始匹配
nextNode = currentNode.children.get(c);
if (nextNode != null) {
currentNode = nextNode;
if (currentNode.isEnd) {
String hitWord = currentNode.word.getWord();
int startIdx = i - hitWord.length() + 1;
hits.add(new HitResult(
hitWord,
currentNode.word.getCategory(),
currentNode.word.getLevel(),
startIdx,
i + 1
));
}
}
}
}
}
return hits;
}
/**
* 将文本中检测到的敏感词替换为 ***
*
* @param text 原始文本
* @return 脱敏后的文本
*/
public String mask(String text) {
List<HitResult> hits = detect(text);
if (hits.isEmpty()) {
return text;
}
// 按起始位置排序
hits.sort(Comparator.comparingInt(HitResult::getStartIndex));
StringBuilder sb = new StringBuilder(text);
int offset = 0; // 记录因替换导致的长度偏移
for (HitResult hit : hits) {
int start = hit.getStartIndex() + offset;
int end = hit.getEndIndex() + offset;
String replacement = "***";
sb.replace(start, end, replacement);
offset += replacement.length() - (hit.getEndIndex() - hit.getStartIndex());
}
return sb.toString();
}
/**
* 判断命中结果中是否存在需要拦截level >= 2的条目
*
* @param hits 命中结果列表
* @return 是否有拦截级别的命中
*/
public boolean hasBlockingHit(List<HitResult> hits) {
return hits.stream().anyMatch(h -> h.getLevel() != null && h.getLevel() >= 2);
}
}

52
src/main/java/com/wok/supportbot/service/ConversationService.java

@ -3,6 +3,7 @@ package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.wok.supportbot.dao.ChatMessageMapper;
import com.wok.supportbot.entity.ChatMessage;
import com.wok.supportbot.entity.MessageFeedback;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
@ -21,6 +22,9 @@ public class ConversationService {
@Autowired
private ChatMessageMapper chatMessageMapper;
@Autowired
private MessageFeedbackService messageFeedbackService;
@Autowired
private JdbcTemplate jdbcTemplate;
@ -328,6 +332,13 @@ public class ConversationService {
return "会话不存在或无任何消息";
}
// 查询该会话的所有反馈 messageId 建立索引
List<MessageFeedback> feedbacks = messageFeedbackService.getByConversationId(conversationId);
Map<String, MessageFeedback> feedbackMap = new HashMap<>();
for (MessageFeedback fb : feedbacks) {
feedbackMap.put(fb.getMessageId(), fb);
}
StringBuilder sb = new StringBuilder();
sb.append("====================================\n");
sb.append("会话导出记录\n");
@ -342,6 +353,10 @@ public class ConversationService {
String type = String.valueOf(msg.get("messageType"));
String content = String.valueOf(msg.get("content"));
String time = String.valueOf(msg.get("createTime"));
// metadata 中可能包含前端生成的 msgId
@SuppressWarnings("unchecked")
Map<String, Object> metadata = (Map<String, Object>) msg.get("metadata");
String msgId = metadata != null ? (String) metadata.get("msgId") : null;
sb.append("[").append(time).append("] ");
switch (type) {
@ -351,7 +366,42 @@ public class ConversationService {
default -> sb.append(type);
}
sb.append("\n");
sb.append(content).append("\n\n");
sb.append(content).append("\n");
// AI 消息后附加反馈信息
if ("ASSISTANT".equals(type) && msgId != null) {
MessageFeedback fb = feedbackMap.get(msgId);
if (fb != null) {
if ("THUMBS_UP".equals(fb.getFeedbackType())) {
sb.append(" 👍 用户反馈: 有帮助\n");
} else if ("THUMBS_DOWN".equals(fb.getFeedbackType())) {
sb.append(" 👎 用户反馈: 没帮助");
String reason = fb.getReasonCategory();
String comment = fb.getReasonComment();
if (reason != null || (comment != null && !comment.isBlank())) {
sb.append(" (");
List<String> parts = new ArrayList<>();
if (reason != null) {
String reasonLabel = switch (reason) {
case "inaccurate" -> "信息错误";
case "irrelevant" -> "不相关";
case "incomplete" -> "不完整";
case "other" -> "其他";
default -> reason;
};
parts.add("原因: " + reasonLabel);
}
if (comment != null && !comment.isBlank()) {
parts.add("补充: " + comment);
}
sb.append(String.join(", ", parts));
sb.append(")");
}
sb.append("\n");
}
}
}
sb.append("\n");
}
sb.append("====================================\n");

33
src/main/java/com/wok/supportbot/service/DocumentService.java

@ -532,6 +532,39 @@ public class DocumentService {
return searchResults;
}
/**
* 多模式搜索P0-001: 支持向量/关键词/混合检索
*
* @param searchMode 检索模式VECTOR默认/ KEYWORD / HYBRID
*/
public List<SearchResult> searchDocuments(String query, int topK, double similarityThreshold,
List<Long> categoryIds, String searchMode) {
if (searchMode == null || searchMode.isEmpty() || "VECTOR".equalsIgnoreCase(searchMode)) {
// 默认向量检索向后兼容
List<SearchResult> results = searchDocuments(query, topK, similarityThreshold, categoryIds);
results.forEach(r -> r.setSearchMode("VECTOR"));
return results;
}
// 委托给 HybridSearchService 处理 KEYWORD HYBRID 模式
if (hybridSearchService != null) {
com.wok.supportbot.rag.SearchMode mode;
try {
mode = com.wok.supportbot.rag.SearchMode.valueOf(searchMode.toUpperCase());
} catch (IllegalArgumentException e) {
mode = com.wok.supportbot.rag.SearchMode.VECTOR;
}
return hybridSearchService.search(query, mode, topK, similarityThreshold, categoryIds);
}
// HybridSearchService 不可用时降级到向量检索
log.warn("HybridSearchService 不可用,降级为向量检索");
return searchDocuments(query, topK, similarityThreshold, categoryIds);
}
@Autowired(required = false)
private com.wok.supportbot.rag.HybridSearchService hybridSearchService;
private List<String> normalizeCategoryIds(List<Long> categoryIds) {
if (categoryIds == null || categoryIds.isEmpty()) {
return Collections.emptyList();

363
src/main/java/com/wok/supportbot/service/FaqMatchEngine.java

@ -0,0 +1,363 @@
package com.wok.supportbot.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.config.EmbeddingModelFactory;
import com.wok.supportbot.dao.KnowledgeFaqMapper;
import com.wok.supportbot.entity.KnowledgeFaq;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.CompletableFuture;
/**
* FAQ 三级匹配引擎
* 1. 精确匹配问题文本完全一致
* 2. 关键词匹配similar_questions 字段包含用户问题中的关键词
* 3. 语义匹配基于向量余弦距离的相似度匹配
*/
@Service
@Slf4j
public class FaqMatchEngine {
@Autowired
private KnowledgeFaqMapper faqMapper;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private EmbeddingModelFactory embeddingModelFactory;
/** 向量维度,与 PgVectorStore 保持一致 */
@Value("${knowledge.vector.dimension:1024}")
private int vectorDimension;
/** 语义匹配相似度阈值 */
@Value("${knowledge.faq.semantic-threshold:0.85}")
private double semanticThreshold;
private final ObjectMapper objectMapper = new ObjectMapper();
// ==================== 匹配结果内部类 ====================
/**
* FAQ 匹配结果
*/
@Data
@AllArgsConstructor
public static class FaqMatchResult {
/** 匹配到的 FAQ */
private KnowledgeFaq faq;
/** 匹配类型: EXACT / KEYWORD / SEMANTIC */
private String matchType;
/** 匹配分数 (0.0 ~ 1.0) */
private double score;
}
// ==================== 核心匹配方法 ====================
/**
* 对用户问题进行三级匹配
*
* @param question 用户问题
* @return 匹配结果可能为空
*/
public Optional<FaqMatchResult> match(String question) {
if (question == null || question.isBlank()) {
return Optional.empty();
}
String trimmedQuestion = question.trim();
// 第一级精确匹配
Optional<FaqMatchResult> exactResult = exactMatch(trimmedQuestion);
if (exactResult.isPresent()) {
log.info("FAQ 精确匹配命中: question={}", trimmedQuestion);
return exactResult;
}
// 第二级关键词匹配
Optional<FaqMatchResult> keywordResult = keywordMatch(trimmedQuestion);
if (keywordResult.isPresent()) {
log.info("FAQ 关键词匹配命中: question={}", trimmedQuestion);
return keywordResult;
}
// 第三级语义匹配
Optional<FaqMatchResult> semanticResult = semanticMatch(trimmedQuestion);
if (semanticResult.isPresent()) {
log.info("FAQ 语义匹配命中: question={}, score={}", trimmedQuestion, semanticResult.get().getScore());
return semanticResult;
}
log.debug("FAQ 未匹配: question={}", trimmedQuestion);
return Optional.empty();
}
// ==================== 第一级精确匹配 ====================
/**
* 精确匹配问题文本完全一致
*/
private Optional<FaqMatchResult> exactMatch(String question) {
try {
List<KnowledgeFaq> results = jdbcTemplate.query(
"SELECT * FROM knowledge_faq WHERE question = ? AND status = 'ENABLED' AND is_delete = false ORDER BY priority DESC LIMIT 1",
(rs, rowNum) -> mapRowToFaq(rs),
question
);
if (!results.isEmpty()) {
KnowledgeFaq faq = results.get(0);
incrementHitCount(faq.getId());
return Optional.of(new FaqMatchResult(faq, "EXACT", 1.0));
}
} catch (Exception e) {
log.error("FAQ 精确匹配异常", e);
}
return Optional.empty();
}
// ==================== 第二级关键词匹配 ====================
/**
* 关键词匹配从用户问题中提取关键词检查 similar_questions 是否包含
*/
private Optional<FaqMatchResult> keywordMatch(String question) {
try {
List<String> keywords = extractKeywords(question);
if (keywords.isEmpty()) {
return Optional.empty();
}
// 构建 ILIKE 条件
StringBuilder sqlBuilder = new StringBuilder(
"SELECT * FROM knowledge_faq WHERE status = 'ENABLED' AND is_delete = false AND ("
);
List<Object> params = new ArrayList<>();
for (int i = 0; i < keywords.size(); i++) {
if (i > 0) {
sqlBuilder.append(" OR ");
}
sqlBuilder.append("similar_questions ILIKE ?");
params.add("%" + keywords.get(i) + "%");
}
sqlBuilder.append(") ORDER BY priority DESC LIMIT 5");
List<KnowledgeFaq> results = jdbcTemplate.query(
sqlBuilder.toString(),
(rs, rowNum) -> mapRowToFaq(rs),
params.toArray()
);
if (!results.isEmpty()) {
KnowledgeFaq faq = results.get(0);
return Optional.of(new FaqMatchResult(faq, "KEYWORD", 0.9));
}
} catch (Exception e) {
log.error("FAQ 关键词匹配异常", e);
}
return Optional.empty();
}
/**
* 简单分词按空格标点切分过滤短词
*/
private List<String> extractKeywords(String question) {
String[] tokens = question.split("[\\s,,。?!?!、;;::\"\"''\\(\\)()\\[\\]【】]+");
List<String> keywords = new ArrayList<>();
for (String token : tokens) {
String trimmed = token.trim();
// 过滤长度小于2的token避免单字匹配过于宽泛
if (trimmed.length() >= 2) {
keywords.add(trimmed);
}
}
return keywords;
}
// ==================== 第三级语义匹配 ====================
/**
* 语义匹配计算问题向量 faq_embedding 表中做余弦距离查询
*/
private Optional<FaqMatchResult> semanticMatch(String question) {
try {
EmbeddingModel embeddingModel = embeddingModelFactory.getEmbeddingModel();
float[] embedding = embeddingModel.call(new EmbeddingRequest(List.of(question), null))
.getResult().getOutput();
// 将向量转为 PGVector 格式字符串
String vectorStr = toPgVectorFormat(embedding);
// 余弦距离查询<=> 运算符返回余弦距离相似度 = 1 - distance
List<Map<String, Object>> results = jdbcTemplate.queryForList(
"SELECT fe.faq_id, fe.embedding <=> ?::vector AS distance, " +
"kf.id, kf.question, kf.answer, kf.similar_questions, kf.category, " +
"kf.status, kf.priority, kf.hit_count, kf.source, kf.create_time, kf.update_time, kf.is_delete " +
"FROM faq_embedding fe " +
"JOIN knowledge_faq kf ON fe.faq_id = kf.id " +
"WHERE kf.status = 'ENABLED' AND kf.is_delete = false " +
"ORDER BY distance ASC LIMIT 5",
vectorStr
);
if (!results.isEmpty()) {
Map<String, Object> topResult = results.get(0);
double distance = ((Number) topResult.get("distance")).doubleValue();
double similarity = 1.0 - distance;
if (similarity >= semanticThreshold) {
KnowledgeFaq faq = mapResultToFaq(topResult);
incrementHitCount(faq.getId());
return Optional.of(new FaqMatchResult(faq, "SEMANTIC", similarity));
}
}
} catch (Exception e) {
log.error("FAQ 语义匹配异常", e);
}
return Optional.empty();
}
// ==================== 向量化方法 ====================
/**
* 计算并保存单条 FAQ 的向量嵌入
*
* @param faqId FAQ ID
* @param question FAQ 问题文本
*/
public void computeAndSaveEmbedding(Long faqId, String question) {
try {
EmbeddingModel embeddingModel = embeddingModelFactory.getEmbeddingModel();
float[] embedding = embeddingModel.call(new EmbeddingRequest(List.of(question), null))
.getResult().getOutput();
String vectorStr = toPgVectorFormat(embedding);
// 获取当前模型名称用于记录
String modelName = "unknown";
try {
var config = embeddingModel.getClass().getSimpleName();
modelName = config;
} catch (Exception ignored) {
}
// 先删除旧记录再插入新记录
jdbcTemplate.update("DELETE FROM faq_embedding WHERE faq_id = ?", faqId);
jdbcTemplate.update(
"INSERT INTO faq_embedding (faq_id, embedding, model_name) VALUES (?, ?::vector, ?)",
faqId, vectorStr, modelName
);
log.info("FAQ 向量已保存: faqId={}, dimension={}", faqId, embedding.length);
} catch (Exception e) {
log.error("FAQ 向量计算/保存失败: faqId={}", faqId, e);
}
}
/**
* 批量异步计算 FAQ 向量嵌入
*
* @param faqIds FAQ ID 列表
*/
public void batchComputeEmbeddings(List<Long> faqIds) {
CompletableFuture.runAsync(() -> {
log.info("开始批量计算 FAQ 向量: count={}", faqIds.size());
int success = 0;
int fail = 0;
for (Long faqId : faqIds) {
try {
// 查询 FAQ 问题文本
String question = jdbcTemplate.queryForObject(
"SELECT question FROM knowledge_faq WHERE id = ? AND is_delete = false",
String.class, faqId
);
if (question != null) {
computeAndSaveEmbedding(faqId, question);
success++;
}
} catch (Exception e) {
log.error("批量计算 FAQ 向量失败: faqId={}", faqId, e);
fail++;
}
}
log.info("批量计算 FAQ 向量完成: success={}, fail={}", success, fail);
});
}
// ==================== 工具方法 ====================
/**
* float[] 转为 PGVector 格式字符串: [0.1,0.2,0.3]
*/
private String toPgVectorFormat(float[] embedding) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < embedding.length; i++) {
if (i > 0) {
sb.append(",");
}
sb.append(embedding[i]);
}
sb.append("]");
return sb.toString();
}
/**
* 增加 FAQ 命中次数
*/
private void incrementHitCount(Long faqId) {
try {
jdbcTemplate.update("UPDATE knowledge_faq SET hit_count = hit_count + 1 WHERE id = ?", faqId);
} catch (Exception e) {
log.warn("更新 FAQ 命中次数失败: faqId={}", faqId, e);
}
}
/**
* ResultSet 映射为 KnowledgeFaq 实体
*/
private KnowledgeFaq mapRowToFaq(java.sql.ResultSet rs) throws java.sql.SQLException {
KnowledgeFaq faq = new KnowledgeFaq();
faq.setId(rs.getLong("id"));
faq.setQuestion(rs.getString("question"));
faq.setAnswer(rs.getString("answer"));
faq.setSimilarQuestions(rs.getString("similar_questions"));
faq.setCategory(rs.getString("category"));
faq.setStatus(rs.getString("status"));
faq.setPriority(rs.getInt("priority"));
faq.setHitCount(rs.getLong("hit_count"));
faq.setSource(rs.getString("source"));
faq.setCreateTime(rs.getTimestamp("create_time"));
faq.setUpdateTime(rs.getTimestamp("update_time"));
faq.setDelete(rs.getBoolean("is_delete"));
return faq;
}
/**
* Map 结果映射为 KnowledgeFaq 实体语义匹配使用
*/
private KnowledgeFaq mapResultToFaq(Map<String, Object> result) {
KnowledgeFaq faq = new KnowledgeFaq();
faq.setId(((Number) result.get("id")).longValue());
faq.setQuestion((String) result.get("question"));
faq.setAnswer((String) result.get("answer"));
faq.setSimilarQuestions((String) result.get("similar_questions"));
faq.setCategory((String) result.get("category"));
faq.setStatus((String) result.get("status"));
faq.setPriority(((Number) result.get("priority")).intValue());
faq.setHitCount(((Number) result.get("hit_count")).longValue());
faq.setSource((String) result.get("source"));
faq.setCreateTime(result.get("create_time") instanceof java.sql.Timestamp ts ? new Date(ts.getTime()) : null);
faq.setUpdateTime(result.get("update_time") instanceof java.sql.Timestamp ts ? new Date(ts.getTime()) : null);
faq.setDelete(Boolean.TRUE.equals(result.get("is_delete")));
return faq;
}
}

299
src/main/java/com/wok/supportbot/service/FaqService.java

@ -0,0 +1,299 @@
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.KnowledgeFaqMapper;
import com.wok.supportbot.entity.KnowledgeFaq;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.concurrent.CompletableFuture;
/**
* FAQ 知识库 CRUD 服务
*/
@Service
@Slf4j
public class FaqService {
@Autowired
private KnowledgeFaqMapper faqMapper;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private FaqMatchEngine faqMatchEngine;
private final ObjectMapper objectMapper = new ObjectMapper();
// ==================== 分页查询 ====================
/**
* 分页查询 FAQ 列表
*
* @param page 页码从1开始
* @param size 每页条数
* @param keyword 关键词搜索question LIKE可选
* @param category 分类过滤可选
* @param status 状态过滤可选
* @return 分页结果 Map {total, records}
*/
public Map<String, Object> list(int page, int size, String keyword, String category, String status) {
QueryWrapper<KnowledgeFaq> wrapper = new QueryWrapper<>();
if (keyword != null && !keyword.isBlank()) {
wrapper.like("question", keyword.trim());
}
if (category != null && !category.isBlank()) {
wrapper.eq("category", category.trim());
}
if (status != null && !status.isBlank()) {
wrapper.eq("status", status.trim());
}
Long total = faqMapper.selectCount(wrapper);
wrapper.orderByDesc("priority").orderByDesc("create_time");
wrapper.last("LIMIT " + size + " OFFSET " + (page - 1) * size);
List<KnowledgeFaq> records = faqMapper.selectList(wrapper);
Map<String, Object> result = new HashMap<>();
result.put("total", total);
result.put("records", records);
return result;
}
// ==================== 新增 ====================
/**
* 新增 FAQ新增后异步计算向量
*/
@Transactional(rollbackFor = Exception.class)
public KnowledgeFaq create(KnowledgeFaq faq) {
// 设置默认值
if (faq.getStatus() == null || faq.getStatus().isBlank()) {
faq.setStatus("ENABLED");
}
if (faq.getPriority() == null) {
faq.setPriority(0);
}
if (faq.getHitCount() == null) {
faq.setHitCount(0L);
}
if (faq.getSource() == null || faq.getSource().isBlank()) {
faq.setSource("manual");
}
if (faq.getSimilarQuestions() == null) {
faq.setSimilarQuestions("[]");
}
faq.setCreateTime(new Date());
faq.setUpdateTime(new Date());
faqMapper.insert(faq);
log.info("FAQ 已创建: id={}, question={}", faq.getId(), faq.getQuestion());
// 异步计算向量
CompletableFuture.runAsync(() -> faqMatchEngine.computeAndSaveEmbedding(faq.getId(), faq.getQuestion()));
return faq;
}
// ==================== 修改 ====================
/**
* 修改 FAQ修改后重新计算向量
*/
@Transactional(rollbackFor = Exception.class)
public KnowledgeFaq update(Long id, KnowledgeFaq faq) {
KnowledgeFaq existing = faqMapper.selectById(id);
if (existing == null) {
throw new IllegalArgumentException("FAQ 不存在: id=" + id);
}
// 更新非空字段
if (faq.getQuestion() != null) {
existing.setQuestion(faq.getQuestion());
}
if (faq.getAnswer() != null) {
existing.setAnswer(faq.getAnswer());
}
if (faq.getSimilarQuestions() != null) {
existing.setSimilarQuestions(faq.getSimilarQuestions());
}
if (faq.getCategory() != null) {
existing.setCategory(faq.getCategory());
}
if (faq.getStatus() != null) {
existing.setStatus(faq.getStatus());
}
if (faq.getPriority() != null) {
existing.setPriority(faq.getPriority());
}
existing.setUpdateTime(new Date());
faqMapper.updateById(existing);
log.info("FAQ 已更新: id={}", id);
// 问题文本变更时重新计算向量
CompletableFuture.runAsync(() -> faqMatchEngine.computeAndSaveEmbedding(id, existing.getQuestion()));
return existing;
}
// ==================== 删除 ====================
/**
* 逻辑删除 FAQ同时删除 faq_embedding 中对应记录
*/
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
KnowledgeFaq existing = faqMapper.selectById(id);
if (existing == null) {
throw new IllegalArgumentException("FAQ 不存在: id=" + id);
}
// 逻辑删除 FAQ
faqMapper.deleteById(id);
// 物理删除对应的向量记录
jdbcTemplate.update("DELETE FROM faq_embedding WHERE faq_id = ?", id);
log.info("FAQ 已删除: id={}", id);
}
// ==================== 启用/禁用 ====================
/**
* 切换 FAQ 启用/禁用状态
*/
@Transactional(rollbackFor = Exception.class)
public void toggleStatus(Long id, String status) {
KnowledgeFaq existing = faqMapper.selectById(id);
if (existing == null) {
throw new IllegalArgumentException("FAQ 不存在: id=" + id);
}
if (!"ENABLED".equals(status) && !"DISABLED".equals(status)) {
throw new IllegalArgumentException("无效的状态值: " + status + ",仅支持 ENABLED/DISABLED");
}
existing.setStatus(status);
existing.setUpdateTime(new Date());
faqMapper.updateById(existing);
log.info("FAQ 状态已切换: id={}, status={}", id, status);
}
// ==================== 批量导入 ====================
/**
* 批量导入 FAQExcel 解析后的数据批量计算向量
*/
@Transactional(rollbackFor = Exception.class)
public int batchImport(List<KnowledgeFaq> faqs) {
if (faqs == null || faqs.isEmpty()) {
return 0;
}
List<Long> importedIds = new ArrayList<>();
for (KnowledgeFaq faq : faqs) {
// 设置默认值
if (faq.getStatus() == null || faq.getStatus().isBlank()) {
faq.setStatus("ENABLED");
}
if (faq.getPriority() == null) {
faq.setPriority(0);
}
if (faq.getHitCount() == null) {
faq.setHitCount(0L);
}
if (faq.getSource() == null || faq.getSource().isBlank()) {
faq.setSource("import");
}
if (faq.getSimilarQuestions() == null) {
faq.setSimilarQuestions("[]");
}
faq.setCreateTime(new Date());
faq.setUpdateTime(new Date());
faqMapper.insert(faq);
importedIds.add(faq.getId());
}
log.info("FAQ 批量导入完成: count={}", importedIds.size());
// 异步批量计算向量
faqMatchEngine.batchComputeEmbeddings(importedIds);
return importedIds.size();
}
// ==================== 导出 ====================
/**
* 导出所有启用的 FAQ
*/
public List<KnowledgeFaq> exportAll() {
QueryWrapper<KnowledgeFaq> wrapper = new QueryWrapper<>();
wrapper.eq("status", "ENABLED");
wrapper.orderByDesc("priority").orderByDesc("hit_count");
return faqMapper.selectList(wrapper);
}
// ==================== 统计 ====================
/**
* 获取 FAQ 匹配统计信息
*
* @return 统计数据 Map
*/
public Map<String, Object> getStats() {
Map<String, Object> stats = new HashMap<>();
// FAQ
Long totalCount = faqMapper.selectCount(new QueryWrapper<>());
stats.put("totalCount", totalCount);
// 启用数
QueryWrapper<KnowledgeFaq> enabledWrapper = new QueryWrapper<>();
enabledWrapper.eq("status", "ENABLED");
Long enabledCount = faqMapper.selectCount(enabledWrapper);
stats.put("enabledCount", enabledCount);
// 总命中次数
Long totalHits = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(hit_count), 0) FROM knowledge_faq WHERE is_delete = false",
Long.class
);
stats.put("totalHits", totalHits);
// Top10 热门 FAQ
List<Map<String, Object>> topFaqs = jdbcTemplate.queryForList(
"SELECT id, question, hit_count, category FROM knowledge_faq " +
"WHERE is_delete = false AND status = 'ENABLED' " +
"ORDER BY hit_count DESC LIMIT 10"
);
stats.put("topFaqs", topFaqs);
return stats;
}
// ==================== 手动重算向量 ====================
/**
* 手动重新计算某条 FAQ 的向量
*/
public void recomputeEmbedding(Long id) {
KnowledgeFaq existing = faqMapper.selectById(id);
if (existing == null) {
throw new IllegalArgumentException("FAQ 不存在: id=" + id);
}
faqMatchEngine.computeAndSaveEmbedding(id, existing.getQuestion());
log.info("FAQ 向量已重新计算: id={}", id);
}
}

119
src/main/java/com/wok/supportbot/service/IntentRouter.java

@ -0,0 +1,119 @@
package com.wok.supportbot.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.config.ChatModelFactory;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* LLM 意图分类路由器
* 使用 ChatModel 对用户问题进行意图分类决定后续处理流程
* - FAQ: 常见问题 FaqMatchEngine 精准匹配
* - RAG: 知识库检索 现有 RAG 流程
* - CHITCHAT: 闲聊 简单对话
*/
@Service
@Slf4j
public class IntentRouter {
@Autowired
private ChatModelFactory chatModelFactory;
private final ObjectMapper objectMapper = new ObjectMapper();
/** 意图分类 Prompt 模板 */
private static final String INTENT_PROMPT_TEMPLATE = """
你是一个意图分类器根据用户问题判断其属于以下哪个意图
- FAQ: 常见问题如产品功能价格退换货政策服务流程等标准问答
- RAG: 需要查阅文档/知识库才能回答的专业问题或细节问题
- CHITCHAT: 闲聊问候感谢告别等非业务话题
仅返回JSON格式: {"intent":"FAQ|RAG|CHITCHAT","confidence":0.0-1.0}
用户问题: %s
""";
// ==================== 意图结果内部类 ====================
/**
* 意图分类结果
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class IntentResult {
/** 意图类型: FAQ / RAG / CHITCHAT */
private String intent;
/** 置信度 (0.0 ~ 1.0) */
private double confidence;
}
// ==================== 核心路由方法 ====================
/**
* 对用户问题进行意图分类
*
* @param userQuestion 用户问题
* @return 意图分类结果
*/
public IntentResult route(String userQuestion) {
if (userQuestion == null || userQuestion.isBlank()) {
return new IntentResult("RAG", 0.0);
}
try {
ChatModel chatModel = chatModelFactory.getChatModel("CHAT");
String promptText = INTENT_PROMPT_TEMPLATE.formatted(userQuestion);
Prompt prompt = new Prompt(promptText);
String response = chatModel.call(prompt).getResult().getOutput().getText();
log.debug("意图分类原始响应: {}", response);
return parseIntentResponse(response);
} catch (Exception e) {
log.error("意图分类失败,降级为 RAG: question={}", userQuestion, e);
return new IntentResult("RAG", 0.0);
}
}
// ==================== 解析方法 ====================
/**
* 解析 LLM 返回的 JSON 意图分类结果
* 如果解析失败默认返回 RAG降级到现有流程
*/
private IntentResult parseIntentResponse(String response) {
try {
// 清理可能的 markdown 代码块包裹
String cleaned = response.trim();
if (cleaned.startsWith("```")) {
cleaned = cleaned.replaceAll("^```(?:json)?\\s*", "").replaceAll("\\s*```$", "");
}
IntentResult result = objectMapper.readValue(cleaned, IntentResult.class);
// 校验意图类型有效性
if (result.getIntent() == null || !isValidIntent(result.getIntent())) {
log.warn("无效的意图类型: {}, 降级为 RAG", result.getIntent());
return new IntentResult("RAG", 0.5);
}
return result;
} catch (Exception e) {
log.warn("意图分类 JSON 解析失败,降级为 RAG: response={}", response, e);
return new IntentResult("RAG", 0.0);
}
}
/**
* 校验意图类型是否有效
*/
private boolean isValidIntent(String intent) {
return "FAQ".equals(intent) || "RAG".equals(intent) || "CHITCHAT".equals(intent);
}
}

178
src/main/java/com/wok/supportbot/service/MessageFeedbackService.java

@ -0,0 +1,178 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.wok.supportbot.dao.MessageFeedbackMapper;
import com.wok.supportbot.entity.MessageFeedback;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.*;
/**
* 消息反馈服务
* 提供反馈提交查询统计等功能
*/
@Service
@Slf4j
public class MessageFeedbackService {
@Autowired
private MessageFeedbackMapper messageFeedbackMapper;
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 提交/修改反馈upsert 语义
* messageId 查询存在则更新覆盖上次不存在则插入
*
* @param feedback 反馈信息
* @return 保存后的反馈实体
*/
public MessageFeedback submitFeedback(MessageFeedback feedback) {
// messageId 查询已有反馈
LambdaQueryWrapper<MessageFeedback> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(MessageFeedback::getMessageId, feedback.getMessageId());
MessageFeedback existing = messageFeedbackMapper.selectOne(wrapper);
if (existing != null) {
// 更新已有反馈
existing.setFeedbackType(feedback.getFeedbackType());
existing.setReasonCategory(feedback.getReasonCategory());
existing.setReasonComment(feedback.getReasonComment());
existing.setUpdateTime(new Date());
messageFeedbackMapper.updateById(existing);
log.info("更新反馈: messageId={}, type={}", feedback.getMessageId(), feedback.getFeedbackType());
return existing;
} else {
// 新增反馈
feedback.setCreateTime(new Date());
feedback.setUpdateTime(new Date());
messageFeedbackMapper.insert(feedback);
log.info("新增反馈: messageId={}, type={}", feedback.getMessageId(), feedback.getFeedbackType());
return feedback;
}
}
/**
* 按会话ID查询所有反馈
*
* @param conversationId 会话ID
* @return 反馈列表
*/
public List<MessageFeedback> getByConversationId(String conversationId) {
LambdaQueryWrapper<MessageFeedback> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(MessageFeedback::getConversationId, conversationId)
.orderByDesc(MessageFeedback::getCreateTime);
return messageFeedbackMapper.selectList(wrapper);
}
/**
* 按消息ID查询反馈
*
* @param messageId 消息ID
* @return 反馈实体可能为 null
*/
public MessageFeedback getByMessageId(String messageId) {
LambdaQueryWrapper<MessageFeedback> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(MessageFeedback::getMessageId, messageId);
return messageFeedbackMapper.selectOne(wrapper);
}
/**
* 批量查询反馈状态 SDK 回显使用
*
* @param messageIds 消息ID列表
* @return 反馈列表
*/
public List<MessageFeedback> getBatchByMessageIds(List<String> messageIds) {
if (messageIds == null || messageIds.isEmpty()) {
return Collections.emptyList();
}
LambdaQueryWrapper<MessageFeedback> wrapper = new LambdaQueryWrapper<>();
wrapper.in(MessageFeedback::getMessageId, messageIds);
return messageFeedbackMapper.selectList(wrapper);
}
/**
* 获取反馈统计数据
*
* @param startDate 开始日期yyyy-MM-dd可选
* @param endDate 结束日期yyyy-MM-dd可选
* @return 统计结果 Map
*/
public Map<String, Object> getStats(String startDate, String endDate) {
Map<String, Object> result = new LinkedHashMap<>();
// 构建日期过滤条件
StringBuilder whereClause = new StringBuilder("WHERE is_delete = false ");
List<Object> params = new ArrayList<>();
if (startDate != null && !startDate.isBlank()) {
whereClause.append("AND create_time >= ?::timestamp ");
params.add(startDate + " 00:00:00");
}
if (endDate != null && !endDate.isBlank()) {
whereClause.append("AND create_time <= ?::timestamp ");
params.add(endDate + " 23:59:59");
}
// 总反馈数👍👎
String countSql = """
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE feedback_type = 'THUMBS_UP') AS thumbs_up,
COUNT(*) FILTER (WHERE feedback_type = 'THUMBS_DOWN') AS thumbs_down
FROM message_feedback
""" + whereClause;
Map<String, Object> counts = jdbcTemplate.queryForMap(countSql, params.toArray());
long totalFeedbacks = ((Number) counts.get("total")).longValue();
long thumbsUpCount = ((Number) counts.get("thumbs_up")).longValue();
long thumbsDownCount = ((Number) counts.get("thumbs_down")).longValue();
result.put("totalFeedbacks", totalFeedbacks);
result.put("thumbsUpCount", thumbsUpCount);
result.put("thumbsDownCount", thumbsDownCount);
result.put("satisfactionRate", totalFeedbacks > 0
? Math.round((double) thumbsUpCount / totalFeedbacks * 100.0) / 100.0
: 0.0);
// 各原因分布仅统计 THUMBS_DOWN
String reasonSql = """
SELECT reason_category, COUNT(*) AS cnt
FROM message_feedback
""" + whereClause + " AND feedback_type = 'THUMBS_DOWN' AND reason_category IS NOT NULL " +
"GROUP BY reason_category ORDER BY cnt DESC";
List<Map<String, Object>> reasonRows = jdbcTemplate.queryForList(reasonSql, params.toArray());
Map<String, Long> reasonDistribution = new LinkedHashMap<>();
for (Map<String, Object> row : reasonRows) {
String category = (String) row.get("reason_category");
long cnt = ((Number) row.get("cnt")).longValue();
reasonDistribution.put(category, cnt);
}
result.put("reasonDistribution", reasonDistribution);
// 按天趋势
String trendSql = """
SELECT
DATE(create_time) AS date,
COUNT(*) FILTER (WHERE feedback_type = 'THUMBS_UP') AS up,
COUNT(*) FILTER (WHERE feedback_type = 'THUMBS_DOWN') AS down
FROM message_feedback
""" + whereClause +
" GROUP BY DATE(create_time) ORDER BY date ASC";
List<Map<String, Object>> trendRows = jdbcTemplate.queryForList(trendSql, params.toArray());
List<Map<String, Object>> dailyTrends = new ArrayList<>();
for (Map<String, Object> row : trendRows) {
Map<String, Object> trend = new LinkedHashMap<>();
trend.put("date", String.valueOf(row.get("date")));
trend.put("up", ((Number) row.get("up")).longValue());
trend.put("down", ((Number) row.get("down")).longValue());
dailyTrends.add(trend);
}
result.put("dailyTrends", dailyTrends);
return result;
}
}

209
src/main/java/com/wok/supportbot/service/SensitiveWordService.java

@ -0,0 +1,209 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.dao.SensitiveWordMapper;
import com.wok.supportbot.entity.SensitiveWord;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 敏感词 CRUD 服务
*/
@Slf4j
@Service
public class SensitiveWordService {
@Autowired
private SensitiveWordMapper sensitiveWordMapper;
@Autowired
private ContentSafetyService contentSafetyService;
/**
* 分页查询敏感词列表
*
* @param page 页码从1开始
* @param size 每页条数
* @param keyword 关键词搜索匹配 word 字段
* @param category 分类筛选
* @return 包含 records/total/page/size/pages 的结果
*/
public Map<String, Object> list(int page, int size, String keyword, String category) {
// 构建计数条件
QueryWrapper<SensitiveWord> countWrapper = new QueryWrapper<>();
if (keyword != null && !keyword.isBlank()) {
countWrapper.like("word", keyword);
}
if (category != null && !category.isBlank()) {
countWrapper.eq("category", category);
}
Long total = sensitiveWordMapper.selectCount(countWrapper);
// 构建列表查询条件
QueryWrapper<SensitiveWord> listWrapper = new QueryWrapper<>();
if (keyword != null && !keyword.isBlank()) {
listWrapper.like("word", keyword);
}
if (category != null && !category.isBlank()) {
listWrapper.eq("category", category);
}
listWrapper.orderByDesc("create_time");
listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size);
List<SensitiveWord> records = sensitiveWordMapper.selectList(listWrapper);
// 组装返回结果
Map<String, Object> result = new HashMap<>();
result.put("records", records);
result.put("total", total);
result.put("page", page);
result.put("size", size);
result.put("pages", (total + size - 1) / size);
return result;
}
/**
* 新增敏感词检查重复
*
* @param word 敏感词实体
* @return 新增后的实体
*/
public SensitiveWord create(SensitiveWord word) {
// 检查是否已存在相同的敏感词
QueryWrapper<SensitiveWord> wrapper = new QueryWrapper<>();
wrapper.eq("word", word.getWord());
Long count = sensitiveWordMapper.selectCount(wrapper);
if (count > 0) {
throw new IllegalArgumentException("敏感词已存在:" + word.getWord());
}
// 设置默认值
if (word.getCategory() == null || word.getCategory().isBlank()) {
word.setCategory("custom");
}
if (word.getLevel() == null) {
word.setLevel(1);
}
if (word.getIsActive() == null) {
word.setIsActive(true);
}
sensitiveWordMapper.insert(word);
// 刷新 DFA 字典树
contentSafetyService.rebuild();
log.info("新增敏感词:{}", word.getWord());
return word;
}
/**
* 修改敏感词
*
* @param id 敏感词ID
* @param word 更新内容
* @return 更新后的实体
*/
public SensitiveWord update(Long id, SensitiveWord word) {
SensitiveWord existing = sensitiveWordMapper.selectById(id);
if (existing == null) {
throw new IllegalArgumentException("敏感词不存在,ID:" + id);
}
// 如果修改了 word 内容检查新词是否与其他记录重复
if (word.getWord() != null && !word.getWord().equals(existing.getWord())) {
QueryWrapper<SensitiveWord> wrapper = new QueryWrapper<>();
wrapper.eq("word", word.getWord());
wrapper.ne("id", id);
Long count = sensitiveWordMapper.selectCount(wrapper);
if (count > 0) {
throw new IllegalArgumentException("敏感词已存在:" + word.getWord());
}
}
word.setId(id);
sensitiveWordMapper.updateById(word);
// 刷新 DFA 字典树
contentSafetyService.rebuild();
log.info("修改敏感词,ID:{}", id);
return sensitiveWordMapper.selectById(id);
}
/**
* 逻辑删除敏感词
*
* @param id 敏感词ID
*/
public void delete(Long id) {
SensitiveWord existing = sensitiveWordMapper.selectById(id);
if (existing == null) {
throw new IllegalArgumentException("敏感词不存在,ID:" + id);
}
sensitiveWordMapper.deleteById(id);
// 刷新 DFA 字典树
contentSafetyService.rebuild();
log.info("删除敏感词,ID:{},词:{}", id, existing.getWord());
}
/**
* 批量导入敏感词
*
* @param words 敏感词列表
* @param category 分类
* @param level 级别
* @return 成功导入的数量
*/
public int batchImport(List<String> words, String category, int level) {
if (words == null || words.isEmpty()) {
return 0;
}
String cat = (category != null && !category.isBlank()) ? category : "custom";
int imported = 0;
List<SensitiveWord> toInsert = new ArrayList<>();
for (String w : words) {
String trimmed = w.trim();
if (trimmed.isEmpty()) {
continue;
}
// 检查是否已存在
QueryWrapper<SensitiveWord> wrapper = new QueryWrapper<>();
wrapper.eq("word", trimmed);
Long count = sensitiveWordMapper.selectCount(wrapper);
if (count > 0) {
continue; // 跳过重复词
}
SensitiveWord sw = SensitiveWord.builder()
.word(trimmed)
.category(cat)
.level(level)
.isActive(true)
.build();
toInsert.add(sw);
imported++;
}
// 批量插入
for (SensitiveWord sw : toInsert) {
sensitiveWordMapper.insert(sw);
}
// 刷新 DFA 字典树
if (imported > 0) {
contentSafetyService.rebuild();
log.info("批量导入敏感词完成,成功 {} 个,共提交 {} 个", imported, words.size());
}
return imported;
}
}

6
src/main/resources/add-comments.sql

@ -1,3 +1,9 @@
-- ================================================================
-- 旧版注释补丁脚本(已合并到 init-database.sql)
-- 如需为已存在的表补充注释,请使用桌面上的 add-table-comments.sql
-- 或直接参考 init-database.sql 中的 COMMENT ON 语句
-- ================================================================
-- 重新设置 chat_message 表注释
COMMENT ON TABLE chat_message IS '聊天消息表(存储用户与AI助手的对话历史)';

3
src/main/resources/application.yml

@ -61,6 +61,9 @@ knowledge:
role:
# 严格隔离:true=角色未绑定知识库分类时禁止检索任何内容;false=可检索全部知识库
strict-isolation: false
faq:
# FAQ 语义匹配阈值(0-1),低于此值自动降级到 RAG 检索
semantic-threshold: 0.85
# ==================== Knife4j API 文档通用配置 ====================
# knife4j.enable 开关在各环境 yml 中配置(生产环境建议关闭)

210
src/main/resources/init-database.sql

@ -1,6 +1,7 @@
-- ============================================================
-- AI 智能客服系统 - 数据库初始化脚本
-- AI 智能客服系统 - 数据库初始化脚本(完整版)
-- 适用环境: PostgreSQL 12+ 且已安装 pgvector 扩展
-- 表数量: 14 张(基础 8 张 + P0 阶段新增 6 张)
--
-- 使用方法:
-- 方式一(psql 命令行):
@ -287,7 +288,212 @@ COMMENT ON COLUMN vector_store.embedding IS '向量嵌入(维度 1024)';
COMMENT ON COLUMN vector_store.create_time IS '创建时间';
COMMENT ON COLUMN vector_store.update_time IS '更新时间';
-- 全文检索列(混合检索 KEYWORD / HYBRID 模式使用)
ALTER TABLE vector_store ADD COLUMN IF NOT EXISTS content_tsvector tsvector;
CREATE INDEX IF NOT EXISTS idx_vector_store_tsvector ON vector_store USING gin(content_tsvector);
COMMENT ON COLUMN vector_store.content_tsvector IS '全文检索向量列(tsvector,由触发器自动维护)';
-- 全文检索触发器函数
CREATE OR REPLACE FUNCTION update_content_tsvector() RETURNS trigger AS $$
BEGIN
NEW.content_tsvector := to_tsvector('simple', coalesce(NEW.content, ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_update_content_tsvector ON vector_store;
CREATE TRIGGER trg_update_content_tsvector
BEFORE INSERT OR UPDATE ON vector_store
FOR EACH ROW EXECUTE FUNCTION update_content_tsvector();
-- ============================================================
-- 表 9: ai_model_config — AI 大模型配置表
-- ============================================================
CREATE TABLE IF NOT EXISTS ai_model_config (
id BIGINT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
app_type VARCHAR(32) NOT NULL,
provider VARCHAR(64) NOT NULL,
api_key VARCHAR(256),
model_name VARCHAR(128) NOT NULL,
temperature DOUBLE PRECISION DEFAULT 0.7,
max_tokens INTEGER DEFAULT 2048,
base_url VARCHAR(256),
extra_config JSONB DEFAULT '{}' NOT NULL,
is_active BOOLEAN DEFAULT FALSE NOT NULL,
priority INTEGER DEFAULT 0 NOT NULL,
description VARCHAR(500),
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_ai_model_config_app_type ON ai_model_config (app_type);
CREATE INDEX IF NOT EXISTS idx_ai_model_config_active ON ai_model_config (is_active) WHERE is_delete = FALSE;
COMMENT ON TABLE ai_model_config IS 'AI 大模型配置表(管理多套模型配置,按应用类型绑定)';
COMMENT ON COLUMN ai_model_config.id IS '主键(雪花算法生成)';
COMMENT ON COLUMN ai_model_config.name IS '配置名称';
COMMENT ON COLUMN ai_model_config.app_type IS '应用类型: CHAT / EMBEDDING / RAG_REWRITE / RERANK';
COMMENT ON COLUMN ai_model_config.provider IS '模型提供商: dashscope / openai / deepseek / moonshot / zhipu / volcengine 等';
COMMENT ON COLUMN ai_model_config.api_key IS 'API Key(数据库加密存储,前端脱敏展示)';
COMMENT ON COLUMN ai_model_config.model_name IS '模型名称(如 qwen-turbo、gpt-4o)';
COMMENT ON COLUMN ai_model_config.temperature IS '温度参数(控制输出随机性,0.0~2.0)';
COMMENT ON COLUMN ai_model_config.max_tokens IS '最大 Token 数(单次生成上限)';
COMMENT ON COLUMN ai_model_config.base_url IS 'API 基础地址(可选,用于私有化部署或第三方厂商)';
COMMENT ON COLUMN ai_model_config.extra_config IS '扩展配置(JSONB,存储 topP、dimensions 等自定义参数)';
COMMENT ON COLUMN ai_model_config.is_active IS '是否激活(每种 App 类型只能有一个激活配置)';
COMMENT ON COLUMN ai_model_config.priority IS '优先级(数值越大越优先,多套配置时生效)';
COMMENT ON COLUMN ai_model_config.description IS '配置描述说明';
COMMENT ON COLUMN ai_model_config.create_time IS '创建时间';
COMMENT ON COLUMN ai_model_config.update_time IS '更新时间';
COMMENT ON COLUMN ai_model_config.is_delete IS '逻辑删除: FALSE=正常 TRUE=已删除';
-- ============================================================
-- 表 10: sensitive_word — 敏感词表(P0-004 内容安全过滤)
-- ============================================================
CREATE TABLE IF NOT EXISTS sensitive_word (
id BIGSERIAL PRIMARY KEY,
word VARCHAR(256) NOT NULL,
category VARCHAR(64) NOT NULL DEFAULT 'custom',
level INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
remark VARCHAR(512),
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_delete BOOLEAN NOT NULL DEFAULT FALSE
);
-- 唯一索引:同一分类下敏感词不重复(仅对未删除记录生效)
CREATE UNIQUE INDEX IF NOT EXISTS uk_sensitive_word_word_category ON sensitive_word (word, category) WHERE is_delete = FALSE;
CREATE INDEX IF NOT EXISTS idx_sensitive_word_active ON sensitive_word (is_active) WHERE is_delete = FALSE;
COMMENT ON TABLE sensitive_word IS '敏感词表(DFA 引擎驱动的内容安全过滤)';
COMMENT ON COLUMN sensitive_word.id IS '主键(雪花算法生成)';
COMMENT ON COLUMN sensitive_word.word IS '敏感词内容';
COMMENT ON COLUMN sensitive_word.category IS '分类: politics / porn / abuse / custom,默认 custom';
COMMENT ON COLUMN sensitive_word.level IS '级别: 1=脱敏(MASK) / 2=拦截(BLOCK)';
COMMENT ON COLUMN sensitive_word.is_active IS '是否启用';
COMMENT ON COLUMN sensitive_word.remark IS '备注说明';
COMMENT ON COLUMN sensitive_word.create_time IS '创建时间';
COMMENT ON COLUMN sensitive_word.update_time IS '更新时间';
COMMENT ON COLUMN sensitive_word.is_delete IS '逻辑删除: FALSE=正常 TRUE=已删除';
-- ============================================================
-- 表 11: content_audit_log — 内容审计日志表(P0-004,只追加不删除)
-- ============================================================
CREATE TABLE IF NOT EXISTS content_audit_log (
id BIGSERIAL PRIMARY KEY,
session_id VARCHAR(64),
direction VARCHAR(16) NOT NULL,
original_text TEXT NOT NULL,
hit_words JSONB NOT NULL DEFAULT '[]',
action_taken VARCHAR(32) NOT NULL,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON content_audit_log (create_time DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_session ON content_audit_log (session_id);
COMMENT ON TABLE content_audit_log IS '内容审计日志表(记录所有敏感词命中事件,只追加不删除)';
COMMENT ON COLUMN content_audit_log.id IS '主键(雪花算法生成)';
COMMENT ON COLUMN content_audit_log.session_id IS '会话ID(关联 conversation_session)';
COMMENT ON COLUMN content_audit_log.direction IS '检测方向: INPUT(用户输入) / OUTPUT(AI 输出)';
COMMENT ON COLUMN content_audit_log.original_text IS '原始违规内容(截断至前 50 字)';
COMMENT ON COLUMN content_audit_log.hit_words IS '命中词列表(JSON 数组,记录触发的敏感词)';
COMMENT ON COLUMN content_audit_log.action_taken IS '采取的动作: PASS(放行) / MASK(脱敏) / BLOCK(拦截)';
COMMENT ON COLUMN content_audit_log.create_time IS '创建时间(事件发生时间)';
-- ============================================================
-- 表 12: message_feedback — 消息反馈表(P0-002 用户反馈系统)
-- ============================================================
CREATE TABLE IF NOT EXISTS message_feedback (
id BIGSERIAL PRIMARY KEY,
message_id VARCHAR(128) NOT NULL,
conversation_id VARCHAR(64) NOT NULL,
feedback_type VARCHAR(16) NOT NULL,
reason_category VARCHAR(64),
reason_comment TEXT,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_delete BOOLEAN NOT NULL DEFAULT FALSE
);
-- 唯一索引:每条消息只能有一条反馈(重复提交覆盖,upsert 语义)
CREATE UNIQUE INDEX IF NOT EXISTS uk_message_feedback_message ON message_feedback (message_id) WHERE is_delete = FALSE;
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);
COMMENT ON TABLE message_feedback IS '消息反馈表(用户对 AI 回复的点赞/点踩反馈)';
COMMENT ON COLUMN message_feedback.id IS '主键(雪花算法生成)';
COMMENT ON COLUMN message_feedback.message_id IS 'AI 消息ID(前端 msgId 或 UUID,唯一索引字段)';
COMMENT ON COLUMN message_feedback.conversation_id IS '会话ID(关联 chat_message)';
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.create_time IS '创建时间';
COMMENT ON COLUMN message_feedback.update_time IS '更新时间(重复提交时覆盖)';
COMMENT ON COLUMN message_feedback.is_delete IS '逻辑删除: FALSE=正常 TRUE=已删除';
-- ============================================================
-- 表 13: knowledge_faq — FAQ 知识库表(P0-003 意图识别 + FAQ 精准匹配)
-- ============================================================
CREATE TABLE IF NOT EXISTS knowledge_faq (
id BIGSERIAL PRIMARY KEY,
question TEXT NOT NULL,
answer TEXT NOT NULL,
similar_questions TEXT NOT NULL DEFAULT '[]',
category VARCHAR(128),
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
);
CREATE INDEX IF NOT EXISTS idx_faq_status ON knowledge_faq (status) WHERE is_delete = FALSE;
CREATE INDEX IF NOT EXISTS idx_faq_category ON knowledge_faq (category);
CREATE INDEX IF NOT EXISTS idx_faq_priority ON knowledge_faq (priority DESC);
COMMENT ON TABLE knowledge_faq IS 'FAQ 知识库表(用于意图路由后的精准问答匹配)';
COMMENT ON COLUMN knowledge_faq.id IS '主键(雪花算法生成)';
COMMENT ON COLUMN knowledge_faq.question IS '标准问题';
COMMENT ON COLUMN knowledge_faq.answer IS '标准答案';
COMMENT ON COLUMN knowledge_faq.similar_questions IS '相似问题列表(JSON 字符串数组,用于扩展匹配范围)';
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.create_time IS '创建时间';
COMMENT ON COLUMN knowledge_faq.update_time IS '更新时间';
COMMENT ON COLUMN knowledge_faq.is_delete IS '逻辑删除: FALSE=正常 TRUE=已删除';
-- ============================================================
-- 表 14: faq_embedding — FAQ 向量索引表(P0-003,FAQ 语义匹配用)
-- ============================================================
CREATE TABLE IF NOT EXISTS faq_embedding (
id BIGSERIAL PRIMARY KEY,
faq_id BIGINT NOT NULL,
embedding vector(1024) NOT NULL,
model_name VARCHAR(64) NOT NULL,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_faq_emb_faq_id ON faq_embedding (faq_id);
COMMENT ON TABLE faq_embedding IS 'FAQ 向量索引表(存储 FAQ 的语义向量,用于相似问题语义匹配)';
COMMENT ON COLUMN faq_embedding.id IS '主键';
COMMENT ON COLUMN faq_embedding.faq_id IS '关联 FAQ ID(对应 knowledge_faq.id)';
COMMENT ON COLUMN faq_embedding.embedding IS '向量嵌入(维度由 knowledge.vector.dimension 配置,默认 1024)';
COMMENT ON COLUMN faq_embedding.model_name IS '使用的 Embedding 模型名称(用于检测模型变更后重新计算)';
COMMENT ON COLUMN faq_embedding.create_time IS '创建时间';
-- ============================================================
-- 完成!
-- 共创建 8 张表及对应索引。
-- 共创建 14 张表及对应索引。
-- ============================================================

4
src/main/resources/knowledge-base.sql

@ -1,6 +1,7 @@
-- ================================================================
-- 知识库管理增强 - 数据库变更脚本
-- 说明: 为知识库管理功能添加分类表和文档管理表
-- 注意: 此脚本为早期版本,完整初始化请使用 init-database.sql
-- ================================================================
-- ================================================================
@ -48,6 +49,7 @@ CREATE TABLE IF NOT EXISTS knowledge_document (
chunk_count INTEGER DEFAULT 0 NOT NULL,
status VARCHAR(20) DEFAULT 'PROCESSING' NOT NULL,
error_message TEXT,
content_hash VARCHAR(64),
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
is_delete BOOLEAN DEFAULT FALSE NOT NULL
@ -68,6 +70,7 @@ COMMENT ON COLUMN knowledge_document.tags IS '标签列表(JSON数组)';
COMMENT ON COLUMN knowledge_document.chunk_count IS '分块数量';
COMMENT ON COLUMN knowledge_document.status IS '处理状态 - PROCESSING/READY/FAILED';
COMMENT ON COLUMN knowledge_document.error_message IS '处理失败时的错误信息';
COMMENT ON COLUMN knowledge_document.content_hash IS '内容 SHA-256 哈希值(用于文档去重校验)';
COMMENT ON COLUMN knowledge_document.create_time IS '创建时间';
COMMENT ON COLUMN knowledge_document.update_time IS '更新时间';
COMMENT ON COLUMN knowledge_document.is_delete IS '删除标志 - false:未删除, true:已删除(逻辑删除)';
@ -76,3 +79,4 @@ COMMENT ON COLUMN knowledge_document.is_delete IS '删除标志 - false:未删
CREATE INDEX IF NOT EXISTS idx_knowledge_document_category ON knowledge_document (category_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_document_status ON knowledge_document (status);
CREATE INDEX IF NOT EXISTS idx_knowledge_document_create_time ON knowledge_document (create_time DESC);
CREATE INDEX IF NOT EXISTS idx_knowledge_document_content_hash ON knowledge_document (content_hash);

41
src/main/resources/static/components/ChatPanel.js

@ -2,7 +2,7 @@
* 智能客服对话面板
*/
import { ref, computed, nextTick, onMounted } from 'vue'
import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, getRoleList, getAccountList, truncateConversation, ragSources } from '../js/api.js'
import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, getRoleList, getAccountList, truncateConversation, ragSources, submitFeedback as submitFeedbackApi } from '../js/api.js'
import { toast, readSSEStream, renderMarkdown } from '../js/utils.js'
import { store } from '../js/store.js'
import MessageSources from './MessageSources.js'
@ -124,6 +124,10 @@ export default {
<button v-if="m.role === 'assistant' && m.content && !m.streaming" @click="regenerate(i)">重新生成</button>
<button v-if="m.role === 'user' && !m.streaming" @click="startEditMessage(i)">编辑</button>
<button v-if="m.error" @click="retryLast">重试</button>
<template v-if="m.role === 'assistant' && m.content && !m.streaming">
<button @click="submitFeedback(m.id, 'up')" :style="{color: m.feedback === 'up' ? 'var(--primary)' : ''}" title="有帮助">👍</button>
<button @click="submitFeedback(m.id, 'down')" :style="{color: m.feedback === 'down' ? '#dc3545' : ''}" title="没帮助">👎</button>
</template>
</div>
</template>
</div>
@ -201,6 +205,10 @@ export default {
return new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
function generateMsgId() {
return 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8)
}
function newChatId() {
chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
}
@ -301,9 +309,9 @@ export default {
userInput.value = ''
lastUserInput.value = text
isSending.value = true
messages.value.push({ role: 'user', content: text, streaming: false, time: formatTime() })
messages.value.push({ id: generateMsgId(), role: 'user', content: text, streaming: false, time: formatTime() })
const assistantMsg = { role: 'assistant', content: '', streaming: true, time: formatTime(), sources: [] }
const assistantMsg = { id: generateMsgId(), role: 'assistant', content: '', streaming: true, time: formatTime(), sources: [] }
messages.value.push(assistantMsg)
await scrollToBottom()
@ -426,6 +434,30 @@ export default {
}
}
// ===== P0-002: 消息反馈 =====
async function submitFeedback(msgId, type) {
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
try {
await submitFeedbackApi({
messageId: String(msgId),
conversationId: chatId.value,
feedbackType: newFeedback ? (newFeedback === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN') : null
})
if (newFeedback) {
toast(newFeedback === 'up' ? '感谢反馈 👍' : '感谢反馈,我们会持续改进', 'success')
}
} catch (e) {
console.error('反馈提交失败:', e)
}
}
onMounted(() => {
newChatId()
store.loadCategories()
@ -464,7 +496,8 @@ export default {
startEditMessage,
cancelEdit,
submitEdit,
regenerate
regenerate,
submitFeedback
}
}
}

42
src/main/resources/static/components/DocSearch.js

@ -1,5 +1,5 @@
/**
* 🔍 语义搜索测试
* 🔍 知识库搜索测试支持多种检索模式
*/
import { ref } from 'vue'
import { searchDocuments } from '../js/api.js'
@ -8,15 +8,23 @@ import { toast } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>语义搜索测试</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">输入查询语句测试知识库检索效果</p>
<h2>知识库搜索测试</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">输入查询语句测试知识库检索效果支持向量关键词混合检索三种模式</p>
<div class="input-row">
<input class="input" v-model="query" placeholder="输入查询,如:Spring Boot 是什么?" @keydown.enter="search">
<label class="input-field-sm">
<span>检索模式</span>
<select class="input input-sm" v-model="searchMode">
<option value="VECTOR">向量检索</option>
<option value="KEYWORD">关键词检索</option>
<option value="HYBRID">混合检索</option>
</select>
</label>
<label class="input-field-sm">
<span>返回条数</span>
<input class="input input-sm" v-model.number="topK" type="number" min="1" max="20">
</label>
<label class="input-field-sm">
<label v-if="searchMode !== 'KEYWORD'" class="input-field-sm">
<span>相似度阈值</span>
<input class="input input-sm" v-model.number="threshold" type="number" min="0" max="1" step="0.1">
</label>
@ -31,7 +39,11 @@ export default {
<div v-else>
<div v-for="(r, i) in results" :key="i" class="search-result">
<div class="score">相似度得分: {{ r.score !== null ? (1 - r.score).toFixed(4) : '-' }} | {{ r.title || '无标题' }} | {{ r.sourceName || '无来源' }}</div>
<div class="score">
<span style="display:inline-block;padding:1px 6px;border-radius:3px;font-size:11px;margin-right:6px;"
:style="{background: modeColor(r.searchMode), color: '#fff'}">{{ modeLabel(r.searchMode) }}</span>
得分: {{ formatScore(r) }} | {{ r.title || '无标题' }} | {{ r.sourceName || '无来源' }}
</div>
<div class="content">{{ r.content || '' }}</div>
</div>
</div>
@ -41,6 +53,7 @@ export default {
const query = ref('')
const topK = ref(5)
const threshold = ref(0.5)
const searchMode = ref('VECTOR')
const results = ref([])
const isSearching = ref(false)
const searched = ref(false)
@ -55,7 +68,7 @@ export default {
searched.value = false
errorMsg.value = ''
try {
const json = await searchDocuments(query.value, topK.value, threshold.value)
const json = await searchDocuments(query.value, topK.value, threshold.value, null, null, searchMode.value)
if (!json.success) {
errorMsg.value = json.message
results.value = []
@ -72,6 +85,21 @@ export default {
}
}
return { query, topK, threshold, results, isSearching, searched, errorMsg, search }
function formatScore(r) {
if (r.searchMode === 'KEYWORD' || r.searchMode === 'HYBRID') {
return r.score != null ? r.score.toFixed(4) : '-'
}
return r.score != null ? (1 - r.score).toFixed(4) : '-'
}
function modeLabel(m) {
return { VECTOR: '向量', KEYWORD: '关键词', HYBRID: '混合' }[m] || m || '向量'
}
function modeColor(m) {
return { VECTOR: '#007bff', KEYWORD: '#28a745', HYBRID: '#6f42c1' }[m] || '#6c757d'
}
return { query, topK, threshold, searchMode, results, isSearching, searched, errorMsg, search, formatScore, modeLabel, modeColor }
}
}

299
src/main/resources/static/components/FaqManager.js

@ -0,0 +1,299 @@
/**
* FAQ 管理组件
* 支持 CRUD + 批量导入 + 导出 + 启用/禁用
*/
import { listFaqs, createFaq, updateFaq, deleteFaq, toggleFaqStatus, batchImportFaqs, exportFaqs, getFaqStats } from '../js/api.js'
import { toast } from '../js/utils.js'
export default {
template: `
<div class="card" style="margin-top:16px;">
<h3 style="margin-bottom:12px;"> FAQ 精准匹配管理</h3>
<!-- 操作栏 -->
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center;">
<button @click="openAddDialog" style="padding:6px 14px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;"> 添加 FAQ</button>
<button @click="showImportDialog = true" style="padding:6px 14px;background:#6c757d;color:#fff;border:none;border-radius:6px;cursor:pointer;">📥 批量导入</button>
<button @click="doExport" style="padding:6px 14px;background:#28a745;color:#fff;border:none;border-radius:6px;cursor:pointer;">📤 导出</button>
<input v-model="searchKeyword" @input="debouncedSearch" placeholder="搜索问题..." style="margin-left:auto;padding:6px 10px;border:1px solid var(--border);border-radius:6px;width:200px;" />
<select v-model="filterCategory" @change="loadList" style="padding:6px 10px;border:1px solid var(--border);border-radius:6px;">
<option value="">全部分类</option>
<option v-for="c in categories" :key="c" :value="c">{{ c }}</option>
</select>
<select v-model="filterStatus" @change="loadList" style="padding:6px 10px;border:1px solid var(--border);border-radius:6px;">
<option value="">全部状态</option>
<option value="ENABLED">启用</option>
<option value="DISABLED">禁用</option>
</select>
</div>
<!-- 统计概要 -->
<div v-if="stats" style="display:flex;gap:16px;margin-bottom:12px;font-size:13px;color:#666;">
<span>总计: {{ stats.totalCount || 0 }}</span>
<span>启用: {{ stats.enabledCount || 0 }}</span>
<span>总命中: {{ stats.totalHitCount || 0 }}</span>
</div>
<!-- FAQ 表格 -->
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:2px solid var(--border);">
<th style="text-align:left;padding:8px;max-width:200px;">问题</th>
<th style="text-align:left;padding:8px;max-width:200px;">答案摘要</th>
<th style="text-align:center;padding:8px;">分类</th>
<th style="text-align:center;padding:8px;">优先级</th>
<th style="text-align:center;padding:8px;">命中</th>
<th style="text-align:center;padding:8px;">状态</th>
<th style="text-align:right;padding:8px;">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="7" style="text-align:center;padding:20px;">加载中...</td></tr>
<tr v-else-if="faqs.length === 0"><td colspan="7" style="text-align:center;padding:20px;color:#999;">暂无 FAQ</td></tr>
<tr v-for="f in faqs" :key="f.id" style="border-bottom:1px solid var(--border);">
<td style="padding:8px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" :title="f.question">{{ f.question }}</td>
<td style="padding:8px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#666;" :title="f.answer">{{ f.answer }}</td>
<td style="text-align:center;padding:8px;font-size:12px;">{{ f.category || '-' }}</td>
<td style="text-align:center;padding:8px;">{{ f.priority }}</td>
<td style="text-align:center;padding:8px;">{{ f.hitCount }}</td>
<td style="text-align:center;padding:8px;">
<span @click="toggleStatus(f)" style="cursor:pointer;" :style="{color: f.status === 'ENABLED' ? '#28a745' : '#dc3545'}">
{{ f.status === 'ENABLED' ? '✅ 启用' : '❌ 禁用' }}
</span>
</td>
<td style="text-align:right;padding:8px;">
<button @click="openEditDialog(f)" style="padding:3px 8px;background:none;border:1px solid var(--border);border-radius:4px;cursor:pointer;margin-right:4px;">编辑</button>
<button @click="removeFaq(f.id)" style="padding:3px 8px;background:none;border:1px solid #dc3545;color:#dc3545;border-radius:4px;cursor:pointer;">删除</button>
</td>
</tr>
</tbody>
</table>
<!-- 分页 -->
<div v-if="total > pageSize" style="display:flex;justify-content:center;gap:8px;margin-top:12px;">
<button @click="page > 1 && (page--, loadList())" :disabled="page <= 1" style="padding:4px 10px;">上一页</button>
<span style="line-height:32px;"> {{ page }} / {{ Math.ceil(total / pageSize) }} {{ total }} </span>
<button @click="page < Math.ceil(total / pageSize) && (page++, loadList())" :disabled="page >= Math.ceil(total / pageSize)" style="padding:4px 10px;">下一页</button>
</div>
<!-- 新增/编辑弹窗 -->
<div v-if="showFormDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;" @click.self="showFormDialog = false">
<div style="background:var(--card);border-radius:12px;padding:24px;width:560px;max-width:90vw;max-height:90vh;overflow-y:auto;">
<h4 style="margin-bottom:16px;">{{ editingFaq ? '编辑 FAQ' : '添加 FAQ' }}</h4>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">问题 *</label>
<input v-model="form.question" placeholder="输入标准问题" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">标准答案 *</label>
<textarea v-model="form.answer" rows="4" placeholder="输入标准答案" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;"></textarea>
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">相似问题每行一个</label>
<textarea v-model="form.similarQuestionsText" rows="3" placeholder="每行一个相似问法&#10;例如:&#10;怎么退货?&#10;退货流程是什么?" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;font-family:monospace;"></textarea>
</div>
<div style="display:flex;gap:8px;margin-bottom:12px;">
<div style="flex:1;">
<label style="display:block;margin-bottom:4px;font-size:13px;">分类</label>
<input v-model="form.category" placeholder="如: 退货政策" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="width:100px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">优先级</label>
<input v-model.number="form.priority" type="number" min="0" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;">
<button @click="showFormDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button>
<button @click="saveFaq" :disabled="!form.question || !form.answer" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">保存</button>
</div>
</div>
</div>
<!-- 批量导入弹窗 -->
<div v-if="showImportDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;" @click.self="showImportDialog = false">
<div style="background:var(--card);border-radius:12px;padding:24px;width:560px;max-width:90vw;">
<h4 style="margin-bottom:16px;">批量导入 FAQ</h4>
<p style="font-size:13px;color:#666;margin-bottom:12px;">使用 JSON 格式批量导入每条包含 questionanswersimilarQuestions可选数组category可选</p>
<textarea v-model="importJson" rows="10" placeholder='[&#10; {"question":"退货流程","answer":"请先在订单页面...","similarQuestions":["怎么退货","退货步骤"],"category":"退货政策"},&#10; {"question":"运费谁出","answer":"7天内退货运费..."}&#10;]' style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;font-family:monospace;font-size:12px;"></textarea>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:12px;">
<button @click="showImportDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button>
<button @click="doImport" :disabled="!importJson.trim()" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">导入</button>
</div>
</div>
</div>
</div>
`,
data() {
return {
faqs: [],
loading: false,
page: 1,
pageSize: 20,
total: 0,
searchKeyword: '',
filterCategory: '',
filterStatus: '',
categories: [],
stats: null,
showFormDialog: false,
showImportDialog: false,
editingFaq: null,
form: { question: '', answer: '', similarQuestionsText: '', category: '', priority: 0 },
importJson: '',
searchTimer: null
}
},
mounted() {
this.loadList()
this.loadStats()
},
methods: {
async loadList() {
this.loading = true
try {
const res = await listFaqs(this.page, this.pageSize, this.searchKeyword || undefined, this.filterCategory || undefined, this.filterStatus || undefined)
if (res.success) {
this.faqs = res.data?.records || res.data || []
this.total = res.data?.total || res.total || 0
// 提取分类列表
const cats = new Set(this.faqs.map(f => f.category).filter(Boolean))
this.categories = [...cats]
}
} catch (e) {
toast('加载失败: ' + e.message, 'error')
}
this.loading = false
},
async loadStats() {
try {
const res = await getFaqStats()
if (res.success) this.stats = res.data
} catch { /* silent */ }
},
debouncedSearch() {
clearTimeout(this.searchTimer)
this.searchTimer = setTimeout(() => { this.page = 1; this.loadList() }, 300)
},
openAddDialog() {
this.editingFaq = null
this.form = { question: '', answer: '', similarQuestionsText: '', category: '', priority: 0 }
this.showFormDialog = true
},
openEditDialog(f) {
this.editingFaq = f
let similarText = ''
try {
const arr = typeof f.similarQuestions === 'string' ? JSON.parse(f.similarQuestions) : f.similarQuestions
similarText = Array.isArray(arr) ? arr.join('\n') : ''
} catch { similarText = f.similarQuestions || '' }
this.form = { question: f.question, answer: f.answer, similarQuestionsText: similarText, category: f.category || '', priority: f.priority || 0 }
this.showFormDialog = true
},
async saveFaq() {
const similarQuestions = this.form.similarQuestionsText.split('\n').map(s => s.trim()).filter(Boolean)
const data = {
question: this.form.question,
answer: this.form.answer,
similarQuestions: JSON.stringify(similarQuestions),
category: this.form.category || null,
priority: this.form.priority || 0
}
try {
let res
if (this.editingFaq) {
res = await updateFaq(this.editingFaq.id, data)
} else {
res = await createFaq(data)
}
if (res.success) {
toast(this.editingFaq ? '修改成功' : '添加成功', 'success')
this.showFormDialog = false
this.loadList()
this.loadStats()
} else {
toast(res.message || '操作失败', 'error')
}
} catch (e) {
toast('操作失败: ' + e.message, 'error')
}
},
async toggleStatus(f) {
const newStatus = f.status === 'ENABLED' ? 'DISABLED' : 'ENABLED'
try {
const res = await toggleFaqStatus(f.id, newStatus)
if (res.success) {
f.status = newStatus
toast(`${newStatus === 'ENABLED' ? '启用' : '禁用'}`, 'success')
} else {
toast(res.message || '操作失败', 'error')
}
} catch (e) {
toast('操作失败: ' + e.message, 'error')
}
},
async removeFaq(id) {
if (!confirm('确认删除该 FAQ?')) return
try {
const res = await deleteFaq(id)
if (res.success) { toast('删除成功', 'success'); this.loadList(); this.loadStats() }
else toast(res.message || '删除失败', 'error')
} catch (e) {
toast('删除失败: ' + e.message, 'error')
}
},
async doImport() {
try {
const faqs = JSON.parse(this.importJson)
if (!Array.isArray(faqs) || faqs.length === 0) return toast('请输入有效的 FAQ 数组', 'error')
// 处理 similarQuestions 字段
for (const f of faqs) {
if (Array.isArray(f.similarQuestions)) f.similarQuestions = JSON.stringify(f.similarQuestions)
else if (!f.similarQuestions) f.similarQuestions = '[]'
}
const res = await batchImportFaqs({ faqs })
if (res.success) {
toast(`成功导入 ${res.data || faqs.length}`, 'success')
this.showImportDialog = false
this.importJson = ''
this.loadList()
this.loadStats()
} else {
toast(res.message || '导入失败', 'error')
}
} catch (e) {
if (e instanceof SyntaxError) toast('JSON 格式错误', 'error')
else toast('导入失败: ' + e.message, 'error')
}
},
async doExport() {
try {
const res = await exportFaqs()
if (res.success) {
const data = JSON.stringify(res.data, null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = 'faq_export.json'; a.click()
URL.revokeObjectURL(url)
toast('导出成功', 'success')
} else {
toast(res.message || '导出失败', 'error')
}
} catch (e) {
toast('导出失败: ' + e.message, 'error')
}
}
}
}

304
src/main/resources/static/components/SensitiveWordManager.js

@ -0,0 +1,304 @@
/**
* 敏感词管理组件
* 支持 CRUD + 批量导入 + 审计日志查看
*/
import { listSensitiveWords, createSensitiveWord, updateSensitiveWord, deleteSensitiveWord, batchImportSensitiveWords, listAuditLogs } from '../js/api.js'
import { toast } from '../js/utils.js'
export default {
template: `
<div class="card" style="margin-top:16px;">
<h3 style="margin-bottom:12px;">🛡 敏感词管理</h3>
<!-- 操作栏 -->
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center;">
<button @click="showAddDialog = true" style="padding:6px 14px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;"> 添加敏感词</button>
<button @click="showImportDialog = true" style="padding:6px 14px;background:#6c757d;color:#fff;border:none;border-radius:6px;cursor:pointer;">📥 批量导入</button>
<button @click="showAuditLog = !showAuditLog" style="padding:6px 14px;background:#17a2b8;color:#fff;border:none;border-radius:6px;cursor:pointer;">📋 审计日志</button>
<input v-model="searchKeyword" @input="debouncedSearch" placeholder="搜索敏感词..." style="margin-left:auto;padding:6px 10px;border:1px solid var(--border);border-radius:6px;width:200px;" />
<select v-model="filterCategory" @change="loadList" style="padding:6px 10px;border:1px solid var(--border);border-radius:6px;">
<option value="">全部分类</option>
<option value="politics">政治</option>
<option value="porn">色情</option>
<option value="abuse">辱骂</option>
<option value="custom">自定义</option>
</select>
</div>
<!-- 敏感词表格 -->
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:2px solid var(--border);">
<th style="text-align:left;padding:8px;">敏感词</th>
<th style="text-align:left;padding:8px;">分类</th>
<th style="text-align:center;padding:8px;">级别</th>
<th style="text-align:center;padding:8px;">状态</th>
<th style="text-align:right;padding:8px;">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="5" style="text-align:center;padding:20px;">加载中...</td></tr>
<tr v-else-if="words.length === 0"><td colspan="5" style="text-align:center;padding:20px;color:#999;">暂无数据</td></tr>
<tr v-for="w in words" :key="w.id" style="border-bottom:1px solid var(--border);">
<td style="padding:8px;">{{ w.word }}</td>
<td style="padding:8px;"><span style="padding:2px 8px;border-radius:4px;font-size:12px;" :style="{background: categoryColor(w.category)}">{{ categoryLabel(w.category) }}</span></td>
<td style="text-align:center;padding:8px;"><span :style="{color: w.level >= 2 ? '#dc3545' : '#ffc107'}">{{ w.level >= 2 ? '拦截' : '警告' }}</span></td>
<td style="text-align:center;padding:8px;">{{ w.isActive ? '✅' : '❌' }}</td>
<td style="text-align:right;padding:8px;">
<button @click="editWord(w)" style="padding:3px 8px;background:none;border:1px solid var(--border);border-radius:4px;cursor:pointer;margin-right:4px;">编辑</button>
<button @click="removeWord(w.id)" style="padding:3px 8px;background:none;border:1px solid #dc3545;color:#dc3545;border-radius:4px;cursor:pointer;">删除</button>
</td>
</tr>
</tbody>
</table>
<!-- 分页 -->
<div v-if="total > pageSize" style="display:flex;justify-content:center;gap:8px;margin-top:12px;">
<button @click="page > 1 && (page--, loadList())" :disabled="page <= 1" style="padding:4px 10px;">上一页</button>
<span style="line-height:32px;"> {{ page }} / {{ Math.ceil(total / pageSize) }} {{ total }} </span>
<button @click="page < Math.ceil(total / pageSize) && (page++, loadList())" :disabled="page >= Math.ceil(total / pageSize)" style="padding:4px 10px;">下一页</button>
</div>
<!-- 新增/编辑弹窗 -->
<div v-if="showAddDialog || editingWord" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;" @click.self="closeDialog">
<div style="background:var(--card);border-radius:12px;padding:24px;width:420px;max-width:90vw;">
<h4 style="margin-bottom:16px;">{{ editingWord ? '编辑敏感词' : '添加敏感词' }}</h4>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">敏感词</label>
<input v-model="form.word" placeholder="输入敏感词" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">分类</label>
<select v-model="form.category" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;">
<option value="custom">自定义</option>
<option value="politics">政治</option>
<option value="porn">色情</option>
<option value="abuse">辱骂</option>
</select>
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">级别</label>
<select v-model="form.level" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;">
<option :value="1">警告脱敏处理</option>
<option :value="2">拦截阻断回复</option>
</select>
</div>
<div style="margin-bottom:16px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">备注</label>
<input v-model="form.remark" placeholder="可选备注" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;">
<button @click="closeDialog" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button>
<button @click="saveWord" :disabled="!form.word" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">保存</button>
</div>
</div>
</div>
<!-- 批量导入弹窗 -->
<div v-if="showImportDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;" @click.self="showImportDialog = false">
<div style="background:var(--card);border-radius:12px;padding:24px;width:500px;max-width:90vw;">
<h4 style="margin-bottom:16px;">批量导入敏感词</h4>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">每行一个敏感词</label>
<textarea v-model="importText" rows="8" placeholder="每行输入一个敏感词&#10;例如:&#10;违禁词1&#10;违禁词2" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;font-family:monospace;"></textarea>
</div>
<div style="display:flex;gap:8px;margin-bottom:16px;">
<select v-model="importCategory" style="padding:8px;border:1px solid var(--border);border-radius:6px;">
<option value="custom">自定义</option>
<option value="politics">政治</option>
<option value="porn">色情</option>
<option value="abuse">辱骂</option>
</select>
<select v-model="importLevel" style="padding:8px;border:1px solid var(--border);border-radius:6px;">
<option :value="1">警告</option>
<option :value="2">拦截</option>
</select>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;">
<button @click="showImportDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button>
<button @click="doImport" :disabled="!importText.trim()" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">导入</button>
</div>
</div>
</div>
<!-- 审计日志面板 -->
<div v-if="showAuditLog" ref="auditLogPanel" style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px;">
<h4 style="margin-bottom:12px;">📋 内容审计日志</h4>
<table style="width:100%;border-collapse:collapse;font-size:13px;">
<thead>
<tr style="border-bottom:2px solid var(--border);">
<th style="text-align:left;padding:6px;">时间</th>
<th style="text-align:center;padding:6px;">方向</th>
<th style="text-align:left;padding:6px;">违规内容</th>
<th style="text-align:left;padding:6px;">命中词</th>
<th style="text-align:center;padding:6px;">处理</th>
</tr>
</thead>
<tbody>
<tr v-if="auditLogs.length === 0"><td colspan="5" style="text-align:center;padding:12px;color:#999;">暂无审计日志</td></tr>
<tr v-for="log in auditLogs" :key="log.id" style="border-bottom:1px solid var(--border);">
<td style="padding:6px;white-space:nowrap;">{{ formatTime(log.createTime) }}</td>
<td style="text-align:center;padding:6px;"><span :style="{color: log.direction === 'INPUT' ? '#007bff' : '#28a745'}">{{ log.direction === 'INPUT' ? '输入' : '输出' }}</span></td>
<td style="padding:6px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" :title="log.originalText">{{ log.originalText }}</td>
<td style="padding:6px;font-size:12px;">{{ formatHitWords(log.hitWords) }}</td>
<td style="text-align:center;padding:6px;">
<span :style="{color: log.actionTaken === 'BLOCK' ? '#dc3545' : log.actionTaken === 'MASK' ? '#ffc107' : '#28a745'}">
{{ log.actionTaken === 'BLOCK' ? '拦截' : log.actionTaken === 'MASK' ? '脱敏' : '通过' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
`,
data() {
return {
words: [],
loading: false,
page: 1,
pageSize: 20,
total: 0,
searchKeyword: '',
filterCategory: '',
showAddDialog: false,
showImportDialog: false,
showAuditLog: false,
editingWord: null,
form: { word: '', category: 'custom', level: 1, remark: '' },
importText: '',
importCategory: 'custom',
importLevel: 1,
auditLogs: [],
searchTimer: null
}
},
mounted() {
this.loadList()
},
methods: {
async loadList() {
this.loading = true
try {
const res = await listSensitiveWords(this.page, this.pageSize, this.searchKeyword || undefined, this.filterCategory || undefined)
if (res.success) {
this.words = res.data?.records || res.data || []
this.total = res.data?.total || res.total || 0
}
} catch (e) {
toast('加载失败: ' + e.message, 'error')
}
this.loading = false
},
debouncedSearch() {
clearTimeout(this.searchTimer)
this.searchTimer = setTimeout(() => { this.page = 1; this.loadList() }, 300)
},
editWord(w) {
this.editingWord = w
this.form = { word: w.word, category: w.category, level: w.level, remark: w.remark || '' }
},
closeDialog() {
this.showAddDialog = false
this.editingWord = null
this.form = { word: '', category: 'custom', level: 1, remark: '' }
},
async saveWord() {
try {
if (this.editingWord) {
const res = await updateSensitiveWord(this.editingWord.id, this.form)
if (res.success) toast('修改成功', 'success')
else toast(res.message || '修改失败', 'error')
} else {
const res = await createSensitiveWord(this.form)
if (res.success) toast('添加成功', 'success')
else toast(res.message || '添加失败', 'error')
}
this.closeDialog()
this.loadList()
} catch (e) {
toast('操作失败: ' + e.message, 'error')
}
},
async removeWord(id) {
if (!confirm('确认删除该敏感词?')) return
try {
const res = await deleteSensitiveWord(id)
if (res.success) { toast('删除成功', 'success'); this.loadList() }
else toast(res.message || '删除失败', 'error')
} catch (e) {
toast('删除失败: ' + e.message, 'error')
}
},
async doImport() {
const words = this.importText.split('\n').map(s => s.trim()).filter(Boolean)
if (words.length === 0) return toast('请输入敏感词', 'error')
try {
const res = await batchImportSensitiveWords({ words, category: this.importCategory, level: this.importLevel })
if (res.success) {
toast(`成功导入 ${res.data || words.length}`, 'success')
this.showImportDialog = false
this.importText = ''
this.loadList()
} else {
toast(res.message || '导入失败', 'error')
}
} catch (e) {
toast('导入失败: ' + e.message, 'error')
}
},
async loadAuditLogs() {
try {
const res = await listAuditLogs(1, 50)
if (res.success) {
this.auditLogs = res.data?.records || res.data || []
} else {
toast('加载审计日志失败: ' + (res.message || '未知错误'), 'error')
}
} catch (e) {
toast('加载审计日志失败: ' + e.message, 'error')
}
},
categoryLabel(c) { return { politics: '政治', porn: '色情', abuse: '辱骂', custom: '自定义' }[c] || c },
categoryColor(c) { return { politics: '#dc3545', porn: '#e83e8c', abuse: '#fd7e14', custom: '#6c757d' }[c] || '#6c757d' },
formatTime(t) { return t ? new Date(t).toLocaleString('zh-CN') : '' },
formatHitWords(hw) {
if (!hw) return ''
// PostgreSQL JDBC 返回 JSONB 为 PGobject:{"type":"jsonb","value":"...", "null":false}
if (hw.type === 'jsonb' && hw.value) {
try { hw = JSON.parse(hw.value) } catch { return hw.value }
}
if (typeof hw === 'string') { try { hw = JSON.parse(hw) } catch { return hw } }
// 格式:{"hits":[{"word":"xxx","level":2,"category":"politics"}]}
if (hw && hw.hits && Array.isArray(hw.hits)) {
return hw.hits.map(h => `${h.word || ''}(${h.category || ''})`).join('、')
}
if (Array.isArray(hw)) return hw.map(h => typeof h === 'string' ? h : h.word || '').join('、')
return String(hw)
}
},
watch: {
showAuditLog(val) {
if (val) {
this.loadAuditLogs()
this.$nextTick(() => {
if (this.$refs.auditLogPanel) {
this.$refs.auditLogPanel.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
})
}
}
}
}

150
src/main/resources/static/js/api.js

@ -274,10 +274,11 @@ export function batchReprocessDocuments(ids) {
/**
* 语义搜索
*/
export function searchDocuments(query, topK, similarityThreshold, categoryId, categoryIds) {
export function searchDocuments(query, topK, similarityThreshold, categoryId, categoryIds, searchMode) {
const body = { query, topK, similarityThreshold }
if (categoryIds && categoryIds.length) body.categoryIds = categoryIds
if (categoryId) body.categoryId = categoryId
if (searchMode) body.searchMode = searchMode
return postJSON('/document/search', body)
}
@ -507,3 +508,150 @@ export function deleteModelConfig(id) {
export function truncateConversation(conversationId, userTurn) {
return postJSON(`/conversation/${conversationId}/truncate`, { userTurn })
}
// ==================== P0-002: 用户反馈 ====================
/**
* 提交/修改消息反馈upsert 语义
* @param {Object} feedback - { messageId, conversationId, feedbackType, reasonCategory, reasonComment }
*/
export function submitFeedback(feedback) {
return postJSON('/feedback', feedback)
}
/**
* 获取反馈统计
*/
export function getFeedbackStats(startDate, endDate) {
let path = '/feedback/stats'
const params = []
if (startDate) params.push(`startDate=${encodeURIComponent(startDate)}`)
if (endDate) params.push(`endDate=${encodeURIComponent(endDate)}`)
if (params.length) path += '?' + params.join('&')
return getJSON(path)
}
/**
* 按会话查询反馈
*/
export function getFeedbackByConversation(conversationId) {
return getJSON(`/feedback/by-conversation/${encodeURIComponent(conversationId)}`)
}
/**
* 批量查询消息反馈状态供回显
*/
export function getFeedbackBatch(messageIds) {
return getJSON(`/feedback/batch?messageIds=${encodeURIComponent(messageIds.join(','))}`)
}
// ==================== P0-004: 敏感词管理 ====================
/**
* 敏感词分页列表
*/
export function listSensitiveWords(page = 1, size = 20, keyword, category) {
let path = `/sensitive-word/list?page=${page}&size=${size}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (category) path += `&category=${encodeURIComponent(category)}`
return getJSON(path)
}
/**
* 新增敏感词
*/
export function createSensitiveWord(data) {
return postJSON('/sensitive-word', data)
}
/**
* 修改敏感词
*/
export function updateSensitiveWord(id, data) {
return putJSONWithBody(`/sensitive-word/${id}`, data)
}
/**
* 删除敏感词
*/
export function deleteSensitiveWord(id) {
return deleteJSON(`/sensitive-word/${id}`)
}
/**
* 批量导入敏感词
*/
export function batchImportSensitiveWords(data) {
return postJSON('/sensitive-word/batch-import', data)
}
/**
* 查询审计日志
*/
export function listAuditLogs(page = 1, size = 20, sessionId) {
let path = `/sensitive-word/audit-log?page=${page}&size=${size}`
if (sessionId) path += `&sessionId=${encodeURIComponent(sessionId)}`
return getJSON(path)
}
// ==================== P0-003: FAQ 管理 ====================
/**
* FAQ 分页列表
*/
export function listFaqs(page = 1, size = 20, keyword, category, status) {
let path = `/faq/list?page=${page}&size=${size}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (category) path += `&category=${encodeURIComponent(category)}`
if (status) path += `&status=${encodeURIComponent(status)}`
return getJSON(path)
}
/**
* 新增 FAQ
*/
export function createFaq(data) {
return postJSON('/faq', data)
}
/**
* 修改 FAQ
*/
export function updateFaq(id, data) {
return putJSONWithBody(`/faq/${id}`, data)
}
/**
* 删除 FAQ
*/
export function deleteFaq(id) {
return deleteJSON(`/faq/${id}`)
}
/**
* 启用/禁用 FAQ
*/
export function toggleFaqStatus(id, status) {
return putJSON(`/faq/${id}/toggle`, { status })
}
/**
* 批量导入 FAQ
*/
export function batchImportFaqs(data) {
return postJSON('/faq/batch-import', data)
}
/**
* 导出所有 FAQ
*/
export function exportFaqs() {
return getJSON('/faq/export')
}
/**
* FAQ 匹配统计
*/
export function getFaqStats() {
return getJSON('/faq/stats')
}

6
src/main/resources/static/js/app.js

@ -17,6 +17,8 @@ import DocUpload from '../components/DocUpload.js'
import DocDetail from '../components/DocDetail.js'
import ConversationManager from '../components/ConversationManager.js'
import ModelConfigManager from '../components/ModelConfigManager.js'
import SensitiveWordManager from '../components/SensitiveWordManager.js'
import FaqManager from '../components/FaqManager.js'
const app = createApp({
setup() {
@ -75,6 +77,7 @@ const app = createApp({
<category-manager></category-manager>
<doc-list></doc-list>
<doc-upload></doc-upload>
<faq-manager></faq-manager>
</div>
<!-- Tab 3: 会话管理 -->
@ -87,6 +90,7 @@ const app = createApp({
<account-manager></account-manager>
<role-manager></role-manager>
<model-config-manager></model-config-manager>
<sensitive-word-manager></sensitive-word-manager>
</div>
<!-- 文档详情弹窗 -->
@ -110,5 +114,7 @@ app.component('doc-upload', DocUpload)
app.component('doc-detail', DocDetail)
app.component('conversation-manager', ConversationManager)
app.component('model-config-manager', ModelConfigManager)
app.component('sensitive-word-manager', SensitiveWordManager)
app.component('faq-manager', FaqManager)
app.mount('#app')

49
src/main/resources/static/sdk/chatbot-sdk.js

@ -755,6 +755,36 @@ var ChatbotSDK = (function () {
return [];
}
}
// ==================== P0-002: 消息反馈 ====================
/**
* 提交消息反馈点赞/点踩
*/
async function submitFeedbackApi(messageId, feedbackType) {
if (!currentConfig)
return false;
const url = buildUrl('/feedback');
try {
const response = await safeFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messageId: String(messageId),
conversationId: currentConfig.chatId,
feedbackType,
}),
});
if (!response.ok) {
logger.error(`反馈提交失败 status=${response.status}`);
return false;
}
const json = await response.json();
return json.success || false;
}
catch (err) {
logger.error('反馈提交异常', err);
return false;
}
}
/**
* 获取会话列表
*/
@ -3654,8 +3684,7 @@ var ChatbotSDK = (function () {
// ==================== 消息反馈(👍 / 👎) ====================
/**
* 处理消息反馈切换 AI 消息的点赞/点踩状态
* 当前仅前端状态持久化存入 messages 数组 + localStorage
* 预留后端对接位TODO 接口 POST /conversation/message/feedback
* 前端状态持久化存入 messages 数组 + localStorage+ 调用后端 API 记录反馈
*/
function handleFeedback(msgId, value) {
if (!messagesContainer$1)
@ -3673,7 +3702,21 @@ var ChatbotSDK = (function () {
// 持久化(localStorage)
if (config$1)
saveMessages(config$1.integrateId, messages);
logger.info(`消息反馈 msgId=${msgId} value=${newValue || 'cleared'}`);
// 调用后端 API 记录反馈
if (newValue) {
const feedbackType = newValue === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN';
submitFeedbackApi(String(msgId), feedbackType).then(success => {
if (success) {
logger.info(`消息反馈已提交 msgId=${msgId} value=${newValue}`);
}
else {
logger.warn(`消息反馈提交失败 msgId=${msgId}(本地状态已更新)`);
}
});
}
else {
logger.info(`消息反馈已取消 msgId=${msgId}`);
}
}
function autoResizeInput() {
if (!inputEl$1)

2
src/main/resources/static/sdk/chatbot-sdk.js.map
File diff suppressed because it is too large
View File

2
src/main/resources/static/sdk/chatbot-sdk.min.js
File diff suppressed because it is too large
View File

2
src/main/resources/static/sdk/chatbot-sdk.min.js.map
File diff suppressed because it is too large
View File

Loading…
Cancel
Save