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
-
48src/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