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