Browse Source
feat(P0): 完成阶段一全部需求 — 混合检索/内容安全/用户反馈/FAQ精准匹配
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
50 changed files with 4968 additions and 50 deletions
-
34CLAUDE.md
-
49client/dist/chatbot-sdk.js
-
2client/dist/chatbot-sdk.js.map
-
2client/dist/chatbot-sdk.min.js
-
2client/dist/chatbot-sdk.min.js.map
-
33client/src/api.ts
-
18client/src/chat.ts
-
228src/main/java/com/wok/supportbot/advisor/ContentSafetyAdvisor.java
-
5src/main/java/com/wok/supportbot/app/AssistantApp.java
-
351src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
-
12src/main/java/com/wok/supportbot/controller/AiController.java
-
8src/main/java/com/wok/supportbot/controller/DocumentController.java
-
277src/main/java/com/wok/supportbot/controller/FaqController.java
-
146src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java
-
239src/main/java/com/wok/supportbot/controller/SensitiveWordController.java
-
12src/main/java/com/wok/supportbot/dao/ContentAuditLogMapper.java
-
12src/main/java/com/wok/supportbot/dao/KnowledgeFaqMapper.java
-
12src/main/java/com/wok/supportbot/dao/MessageFeedbackMapper.java
-
12src/main/java/com/wok/supportbot/dao/SensitiveWordMapper.java
-
59src/main/java/com/wok/supportbot/entity/ContentAuditLog.java
-
102src/main/java/com/wok/supportbot/entity/KnowledgeFaq.java
-
86src/main/java/com/wok/supportbot/entity/MessageFeedback.java
-
5src/main/java/com/wok/supportbot/entity/SearchResult.java
-
66src/main/java/com/wok/supportbot/entity/SensitiveWord.java
-
283src/main/java/com/wok/supportbot/rag/HybridSearchService.java
-
236src/main/java/com/wok/supportbot/rag/RerankerService.java
-
88src/main/java/com/wok/supportbot/rag/RrfFusion.java
-
19src/main/java/com/wok/supportbot/rag/SearchMode.java
-
209src/main/java/com/wok/supportbot/service/ContentSafetyService.java
-
52src/main/java/com/wok/supportbot/service/ConversationService.java
-
33src/main/java/com/wok/supportbot/service/DocumentService.java
-
363src/main/java/com/wok/supportbot/service/FaqMatchEngine.java
-
299src/main/java/com/wok/supportbot/service/FaqService.java
-
119src/main/java/com/wok/supportbot/service/IntentRouter.java
-
178src/main/java/com/wok/supportbot/service/MessageFeedbackService.java
-
209src/main/java/com/wok/supportbot/service/SensitiveWordService.java
-
6src/main/resources/add-comments.sql
-
3src/main/resources/application.yml
-
210src/main/resources/init-database.sql
-
4src/main/resources/knowledge-base.sql
-
41src/main/resources/static/components/ChatPanel.js
-
42src/main/resources/static/components/DocSearch.js
-
299src/main/resources/static/components/FaqManager.js
-
304src/main/resources/static/components/SensitiveWordManager.js
-
150src/main/resources/static/js/api.js
-
6src/main/resources/static/js/app.js
-
49src/main/resources/static/sdk/chatbot-sdk.js
-
2src/main/resources/static/sdk/chatbot-sdk.js.map
-
2src/main/resources/static/sdk/chatbot-sdk.min.js
-
2src/main/resources/static/sdk/chatbot-sdk.min.js.map
2
client/dist/chatbot-sdk.js.map
File diff suppressed because it is too large
View File
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
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
File diff suppressed because it is too large
View File
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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> { |
||||
|
} |
||||
@ -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> { |
||||
|
} |
||||
@ -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> { |
||||
|
} |
||||
@ -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> { |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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.*; |
||||
|
|
||||
|
/** |
||||
|
* RRF(Reciprocal 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; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
package com.wok.supportbot.rag; |
||||
|
|
||||
|
/** |
||||
|
* 检索模式枚举 |
||||
|
* - VECTOR: 纯向量语义检索 |
||||
|
* - KEYWORD: 全文关键词检索 |
||||
|
* - HYBRID: 混合检索(双路检索 + RRF 融合 + Reranker 精排) |
||||
|
*/ |
||||
|
public enum SearchMode { |
||||
|
|
||||
|
/** 纯向量语义检索 */ |
||||
|
VECTOR, |
||||
|
|
||||
|
/** 混合检索:双路检索 → RRF 融合 → Reranker 精排 */ |
||||
|
HYBRID, |
||||
|
|
||||
|
/** 全文关键词检索 */ |
||||
|
KEYWORD |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
|
||||
|
// ==================== 批量导入 ==================== |
||||
|
|
||||
|
/** |
||||
|
* 批量导入 FAQ(Excel 解析后的数据),批量计算向量 |
||||
|
*/ |
||||
|
@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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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="每行一个相似问法 例如: 怎么退货? 退货流程是什么?" 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 格式批量导入,每条包含 question、answer、similarQuestions(可选数组)、category(可选)</p> |
||||
|
<textarea v-model="importJson" rows="10" placeholder='[ {"question":"退货流程","answer":"请先在订单页面...","similarQuestions":["怎么退货","退货步骤"],"category":"退货政策"}, {"question":"运费谁出","answer":"7天内退货运费..."} ]' 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') |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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="每行输入一个敏感词 例如: 违禁词1 违禁词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' }) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
2
src/main/resources/static/sdk/chatbot-sdk.js.map
File diff suppressed because it is too large
View File
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
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
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save
Reference in new issue