本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

303 lines
9.8 KiB

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, Long categoryId, String status) {
QueryWrapper<KnowledgeFaq> wrapper = new QueryWrapper<>();
if (keyword != null && !keyword.isBlank()) {
wrapper.like("question", keyword.trim());
}
if (categoryId != null) {
wrapper.eq("category_id", categoryId);
}
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.getCategoryId() != null) {
existing.setCategoryId(faq.getCategoryId());
}
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 数
QueryWrapper<KnowledgeFaq> totalWrapper = new QueryWrapper<>();
Long totalCount = faqMapper.selectCount(totalWrapper);
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, category_id 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);
}
}