diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/FaqCategoryController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/FaqCategoryController.java new file mode 100644 index 0000000..7712c09 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/FaqCategoryController.java @@ -0,0 +1,65 @@ +package tech.easyflow.admin.controller.ai; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import tech.easyflow.ai.entity.FaqCategory; +import tech.easyflow.ai.service.FaqCategoryService; +import tech.easyflow.common.annotation.UsePermission; +import tech.easyflow.common.domain.Result; +import tech.easyflow.common.web.controller.BaseCurdController; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.common.web.jsonbody.JsonBody; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/faqCategory") +@UsePermission(moduleName = "/api/v1/documentCollection") +public class FaqCategoryController extends BaseCurdController { + + public FaqCategoryController(FaqCategoryService service) { + super(service); + } + + @Override + @GetMapping("list") + @SaCheckPermission("/api/v1/documentCollection/query") + public Result> list(FaqCategory entity, Boolean asTree, String sortKey, String sortType) { + BigInteger collectionId = entity == null ? null : entity.getCollectionId(); + if (collectionId == null) { + throw new BusinessException("知识库ID不能为空"); + } + return Result.ok(service.listByCollection(collectionId, asTree)); + } + + @Override + @PostMapping("save") + @SaCheckPermission("/api/v1/documentCollection/save") + public Result save(@JsonBody FaqCategory entity) { + return Result.ok(service.saveCategory(entity)); + } + + @Override + @PostMapping("update") + @SaCheckPermission("/api/v1/documentCollection/save") + public Result update(@JsonBody FaqCategory entity) { + return Result.ok(service.updateCategory(entity)); + } + + @Override + @PostMapping("remove") + @SaCheckPermission("/api/v1/documentCollection/remove") + public Result remove(@JsonBody(value = "id", required = true) Serializable id) { + return Result.ok(service.removeCategory(new BigInteger(String.valueOf(id)))); + } + + @Override + protected String getDefaultOrderBy() { + return "sort_no asc"; + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/FaqItemController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/FaqItemController.java index 0b1537b..f95a925 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/FaqItemController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/FaqItemController.java @@ -2,27 +2,36 @@ package tech.easyflow.admin.controller.ai; import cn.dev33.satoken.annotation.SaCheckPermission; import com.mybatisflex.core.paginate.Page; +import com.mybatisflex.core.query.QueryWrapper; import jakarta.servlet.http.HttpServletRequest; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import tech.easyflow.ai.entity.FaqItem; +import tech.easyflow.ai.service.FaqCategoryService; import tech.easyflow.ai.service.FaqItemService; import tech.easyflow.common.annotation.UsePermission; import tech.easyflow.common.domain.Result; import tech.easyflow.common.web.controller.BaseCurdController; +import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.jsonbody.JsonBody; import java.io.Serializable; +import java.math.BigInteger; +import java.util.List; +import java.util.Map; @RestController @RequestMapping("/api/v1/faqItem") @UsePermission(moduleName = "/api/v1/documentCollection") public class FaqItemController extends BaseCurdController { - public FaqItemController(FaqItemService service) { + private final FaqCategoryService faqCategoryService; + + public FaqItemController(FaqItemService service, FaqCategoryService faqCategoryService) { super(service); + this.faqCategoryService = faqCategoryService; } @Override @@ -36,7 +45,49 @@ public class FaqItemController extends BaseCurdController> page(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) { - return super.page(request, sortKey, sortType, pageNumber, pageSize); + if (pageNumber == null || pageNumber < 1) { + pageNumber = 1L; + } + if (pageSize == null || pageSize < 1) { + pageSize = 10L; + } + + String collectionIdText = request.getParameter("collectionId"); + if (collectionIdText == null || collectionIdText.trim().isEmpty()) { + throw new BusinessException("知识库ID不能为空"); + } + BigInteger collectionId = new BigInteger(collectionIdText); + faqCategoryService.ensureDefaultCategory(collectionId); + + QueryWrapper queryWrapper = QueryWrapper.create() + .eq(FaqItem::getCollectionId, collectionId); + + String question = request.getParameter("question"); + if (question != null && !question.trim().isEmpty()) { + queryWrapper.like(FaqItem::getQuestion, question.trim()); + } + + String categoryIdText = request.getParameter("categoryId"); + if (categoryIdText != null && !categoryIdText.trim().isEmpty()) { + BigInteger categoryId = new BigInteger(categoryIdText); + List descendantIds = faqCategoryService.findDescendantIds(collectionId, categoryId); + if (descendantIds.isEmpty()) { + queryWrapper.eq(FaqItem::getId, BigInteger.ZERO); + } else { + queryWrapper.in(FaqItem::getCategoryId, descendantIds); + } + } + + queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy())); + Page page = service.page(new Page<>(pageNumber, pageSize), queryWrapper); + Map pathMap = faqCategoryService.buildPathMap(collectionId); + + if (page.getRecords() != null) { + for (FaqItem record : page.getRecords()) { + record.setCategoryPath(pathMap.get(record.getCategoryId())); + } + } + return Result.ok(page); } @Override diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/FaqCategory.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/FaqCategory.java new file mode 100644 index 0000000..883955e --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/FaqCategory.java @@ -0,0 +1,8 @@ +package tech.easyflow.ai.entity; + +import com.mybatisflex.annotation.Table; +import tech.easyflow.ai.entity.base.FaqCategoryBase; + +@Table("tb_faq_category") +public class FaqCategory extends FaqCategoryBase { +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/FaqItem.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/FaqItem.java index e6bf650..9d3b2b6 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/FaqItem.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/FaqItem.java @@ -1,8 +1,31 @@ package tech.easyflow.ai.entity; +import com.mybatisflex.annotation.Column; import com.mybatisflex.annotation.Table; import tech.easyflow.ai.entity.base.FaqItemBase; @Table("tb_faq_item") public class FaqItem extends FaqItemBase { + + @Column(ignore = true) + private String categoryName; + + @Column(ignore = true) + private String categoryPath; + + public String getCategoryName() { + return categoryName; + } + + public void setCategoryName(String categoryName) { + this.categoryName = categoryName; + } + + public String getCategoryPath() { + return categoryPath; + } + + public void setCategoryPath(String categoryPath) { + this.categoryPath = categoryPath; + } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/FaqCategoryBase.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/FaqCategoryBase.java new file mode 100644 index 0000000..250e19c --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/FaqCategoryBase.java @@ -0,0 +1,158 @@ +package tech.easyflow.ai.entity.base; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import tech.easyflow.common.entity.DateTreeEntity; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.Date; + +public class FaqCategoryBase extends DateTreeEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id(keyType = KeyType.Generator, value = "snowFlakeId", comment = "主键") + private BigInteger id; + + @Column(comment = "知识库ID") + private BigInteger collectionId; + + @Column(comment = "父分类ID,0表示根") + private BigInteger parentId; + + @Column(comment = "祖先路径(逗号分隔)") + private String ancestors; + + @Column(comment = "层级(1-3)") + private Integer levelNo; + + @Column(comment = "分类名称") + private String categoryName; + + @Column(comment = "排序") + private Integer sortNo; + + @Column(comment = "是否默认分类") + private Boolean isDefault; + + @Column(comment = "数据状态") + private Integer status; + + @Column(comment = "创建时间") + private Date created; + + @Column(comment = "创建人") + private BigInteger createdBy; + + @Column(comment = "更新时间") + private Date modified; + + @Column(comment = "更新人") + private BigInteger modifiedBy; + + public BigInteger getId() { + return id; + } + + public void setId(BigInteger id) { + this.id = id; + } + + public BigInteger getCollectionId() { + return collectionId; + } + + public void setCollectionId(BigInteger collectionId) { + this.collectionId = collectionId; + } + + public BigInteger getParentId() { + return parentId; + } + + public void setParentId(BigInteger parentId) { + this.parentId = parentId; + } + + public String getAncestors() { + return ancestors; + } + + public void setAncestors(String ancestors) { + this.ancestors = ancestors; + } + + public Integer getLevelNo() { + return levelNo; + } + + public void setLevelNo(Integer levelNo) { + this.levelNo = levelNo; + } + + public String getCategoryName() { + return categoryName; + } + + public void setCategoryName(String categoryName) { + this.categoryName = categoryName; + } + + public Integer getSortNo() { + return sortNo; + } + + public void setSortNo(Integer sortNo) { + this.sortNo = sortNo; + } + + public Boolean getIsDefault() { + return isDefault; + } + + public void setIsDefault(Boolean isDefault) { + this.isDefault = isDefault; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public BigInteger getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(BigInteger createdBy) { + this.createdBy = createdBy; + } + + public Date getModified() { + return modified; + } + + public void setModified(Date modified) { + this.modified = modified; + } + + public BigInteger getModifiedBy() { + return modifiedBy; + } + + public void setModifiedBy(BigInteger modifiedBy) { + this.modifiedBy = modifiedBy; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/FaqItemBase.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/FaqItemBase.java index a339aab..ce404d6 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/FaqItemBase.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/FaqItemBase.java @@ -20,6 +20,9 @@ public class FaqItemBase implements Serializable { @Column(comment = "知识库ID") private BigInteger collectionId; + @Column(comment = "FAQ分类ID") + private BigInteger categoryId; + @Column(comment = "问题") private String question; @@ -63,6 +66,14 @@ public class FaqItemBase implements Serializable { this.collectionId = collectionId; } + public BigInteger getCategoryId() { + return categoryId; + } + + public void setCategoryId(BigInteger categoryId) { + this.categoryId = categoryId; + } + public String getQuestion() { return question; } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/FaqCategoryMapper.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/FaqCategoryMapper.java new file mode 100644 index 0000000..7e77c93 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/FaqCategoryMapper.java @@ -0,0 +1,7 @@ +package tech.easyflow.ai.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.ai.entity.FaqCategory; + +public interface FaqCategoryMapper extends BaseMapper { +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/FaqCategoryService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/FaqCategoryService.java new file mode 100644 index 0000000..50d194a --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/FaqCategoryService.java @@ -0,0 +1,27 @@ +package tech.easyflow.ai.service; + +import com.mybatisflex.core.service.IService; +import tech.easyflow.ai.entity.FaqCategory; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +public interface FaqCategoryService extends IService { + + BigInteger ensureDefaultCategory(BigInteger collectionId); + + BigInteger ensureFaqItemCategory(BigInteger collectionId, BigInteger categoryId); + + List listByCollection(BigInteger collectionId, Boolean asTree); + + boolean saveCategory(FaqCategory entity); + + boolean updateCategory(FaqCategory entity); + + boolean removeCategory(BigInteger id); + + List findDescendantIds(BigInteger collectionId, BigInteger categoryId); + + Map buildPathMap(BigInteger collectionId); +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/FaqCategoryServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/FaqCategoryServiceImpl.java new file mode 100644 index 0000000..c314936 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/FaqCategoryServiceImpl.java @@ -0,0 +1,555 @@ +package tech.easyflow.ai.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.spring.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.entity.FaqCategory; +import tech.easyflow.ai.entity.FaqItem; +import tech.easyflow.ai.mapper.FaqCategoryMapper; +import tech.easyflow.ai.mapper.FaqItemMapper; +import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.ai.service.FaqCategoryService; +import tech.easyflow.common.tree.Tree; +import tech.easyflow.common.util.StringUtil; +import tech.easyflow.common.web.exceptions.BusinessException; + +import javax.annotation.Resource; +import java.math.BigInteger; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class FaqCategoryServiceImpl extends ServiceImpl implements FaqCategoryService { + + private static final BigInteger ROOT_PARENT_ID = BigInteger.ZERO; + private static final ConcurrentHashMap DEFAULT_CATEGORY_LOCKS = new ConcurrentHashMap<>(); + + @Resource + private DocumentCollectionService documentCollectionService; + + @Resource + private FaqItemMapper faqItemMapper; + + @Override + @Transactional + public BigInteger ensureDefaultCategory(BigInteger collectionId) { + Object lock = DEFAULT_CATEGORY_LOCKS.computeIfAbsent(collectionId, key -> new Object()); + synchronized (lock) { + try { + return ensureDefaultCategoryWithLock(collectionId); + } finally { + DEFAULT_CATEGORY_LOCKS.remove(collectionId, lock); + } + } + } + + private BigInteger ensureDefaultCategoryWithLock(BigInteger collectionId) { + DocumentCollection collection = checkFaqCollection(collectionId); + + QueryWrapper queryWrapper = QueryWrapper.create() + .eq(FaqCategory::getCollectionId, collection.getId()) + .eq(FaqCategory::getIsDefault, true) + .orderBy("id asc"); + List defaultCategories = list(queryWrapper); + FaqCategory defaultCategory; + if (defaultCategories == null || defaultCategories.isEmpty()) { + Date now = new Date(); + BigInteger userId = getCurrentUserId(); + FaqCategory entity = new FaqCategory(); + entity.setCollectionId(collectionId); + entity.setParentId(ROOT_PARENT_ID); + entity.setAncestors("0"); + entity.setLevelNo(1); + entity.setCategoryName("默认分类"); + entity.setSortNo(0); + entity.setIsDefault(true); + entity.setStatus(0); + entity.setCreated(now); + entity.setCreatedBy(userId); + entity.setModified(now); + entity.setModifiedBy(userId); + save(entity); + defaultCategory = entity; + } else { + defaultCategory = defaultCategories.get(0); + if (defaultCategories.size() > 1) { + normalizeDuplicateDefaultCategories(collectionId, defaultCategory, defaultCategories); + } + } + + backfillNullFaqItemCategory(collectionId, defaultCategory.getId()); + return defaultCategory.getId(); + } + + private void normalizeDuplicateDefaultCategories(BigInteger collectionId, + FaqCategory keepCategory, + List defaultCategories) { + if (keepCategory == null || defaultCategories == null || defaultCategories.size() <= 1) { + return; + } + Date now = new Date(); + BigInteger userId = getCurrentUserId(); + + for (FaqCategory duplicate : defaultCategories) { + if (duplicate == null || duplicate.getId() == null || keepCategory.getId().equals(duplicate.getId())) { + continue; + } + + QueryWrapper faqQuery = QueryWrapper.create() + .eq(FaqItem::getCollectionId, collectionId) + .eq(FaqItem::getCategoryId, duplicate.getId()); + FaqItem faqItem = new FaqItem(); + faqItem.setCategoryId(keepCategory.getId()); + faqItem.setModified(now); + faqItem.setModifiedBy(userId); + faqItemMapper.updateByQuery(faqItem, faqQuery); + + QueryWrapper childQuery = QueryWrapper.create() + .eq(FaqCategory::getCollectionId, collectionId) + .eq(FaqCategory::getParentId, duplicate.getId()); + long childCount = mapper.selectCountByQuery(childQuery); + if (childCount <= 0) { + removeById(duplicate.getId()); + continue; + } + + duplicate.setIsDefault(false); + if ("默认分类".equals(duplicate.getCategoryName())) { + duplicate.setCategoryName("默认分类副本-" + duplicate.getId()); + } + duplicate.setModified(now); + duplicate.setModifiedBy(userId); + updateById(duplicate); + } + } + + @Override + @Transactional + public BigInteger ensureFaqItemCategory(BigInteger collectionId, BigInteger categoryId) { + checkFaqCollection(collectionId); + BigInteger defaultCategoryId = ensureDefaultCategory(collectionId); + if (categoryId == null) { + return defaultCategoryId; + } + + FaqCategory category = getById(categoryId); + if (category == null || !collectionId.equals(category.getCollectionId())) { + throw new BusinessException("FAQ分类不存在或不属于当前知识库"); + } + if (category.getStatus() != null && category.getStatus() != 0) { + throw new BusinessException("FAQ分类不可用"); + } + return categoryId; + } + + @Override + public List listByCollection(BigInteger collectionId, Boolean asTree) { + checkFaqCollection(collectionId); + ensureDefaultCategory(collectionId); + + QueryWrapper queryWrapper = QueryWrapper.create() + .eq(FaqCategory::getCollectionId, collectionId) + .orderBy("is_default desc, sort_no asc, id asc"); + List categories = list(queryWrapper); + if (asTree == null || asTree) { + return Tree.tryToTree(categories, "id", "parentId"); + } + return categories; + } + + @Override + @Transactional + public boolean saveCategory(FaqCategory entity) { + if (entity == null) { + throw new BusinessException("FAQ分类不能为空"); + } + if (entity.getCollectionId() == null) { + throw new BusinessException("知识库ID不能为空"); + } + if (StringUtil.noText(entity.getCategoryName())) { + throw new BusinessException("分类名称不能为空"); + } + + checkFaqCollection(entity.getCollectionId()); + BigInteger parentId = normalizeParentId(entity.getParentId()); + validateParentNotDefault(entity.getCollectionId(), parentId); + ParentMeta parentMeta = getParentMeta(entity.getCollectionId(), parentId, null); + + Date now = new Date(); + BigInteger userId = getCurrentUserId(); + entity.setParentId(parentId); + entity.setAncestors(parentMeta.ancestors); + entity.setLevelNo(parentMeta.levelNo); + entity.setCategoryName(entity.getCategoryName().trim()); + if (entity.getSortNo() == null) { + entity.setSortNo(nextSortNo(entity.getCollectionId(), parentId)); + } + entity.setIsDefault(false); + if (entity.getStatus() == null) { + entity.setStatus(0); + } + entity.setCreated(now); + entity.setCreatedBy(userId); + entity.setModified(now); + entity.setModifiedBy(userId); + + return save(entity); + } + + @Override + @Transactional + public boolean updateCategory(FaqCategory entity) { + if (entity == null || entity.getId() == null) { + throw new BusinessException("FAQ分类ID不能为空"); + } + if (StringUtil.noText(entity.getCategoryName())) { + throw new BusinessException("分类名称不能为空"); + } + + FaqCategory old = getById(entity.getId()); + if (old == null) { + throw new BusinessException("FAQ分类不存在"); + } + + checkFaqCollection(old.getCollectionId()); + + BigInteger parentId = normalizeParentId(entity.getParentId() == null ? old.getParentId() : entity.getParentId()); + if (Boolean.TRUE.equals(old.getIsDefault()) && !ROOT_PARENT_ID.equals(parentId)) { + throw new BusinessException("默认分类不能设置父级"); + } + if (!parentId.equals(normalizeParentId(old.getParentId()))) { + validateParentNotDefault(old.getCollectionId(), parentId); + } + + QueryWrapper queryWrapper = QueryWrapper.create() + .eq(FaqCategory::getCollectionId, old.getCollectionId()) + .orderBy("sort_no asc, id asc"); + List allCategories = list(queryWrapper); + Map categoryMap = new HashMap<>(); + for (FaqCategory category : allCategories) { + categoryMap.put(category.getId(), category); + } + + if (!categoryMap.containsKey(old.getId())) { + throw new BusinessException("FAQ分类不存在"); + } + + if (parentId.equals(old.getId())) { + throw new BusinessException("父分类不能是自己"); + } + + Set descendants = findDescendantSet(categoryMap, old.getId()); + if (descendants.contains(parentId)) { + throw new BusinessException("父分类不能是当前分类的子分类"); + } + + ParentMeta parentMeta = getParentMeta(old.getCollectionId(), parentId, old.getId()); + + Date now = new Date(); + BigInteger userId = getCurrentUserId(); + + old.setParentId(parentId); + old.setAncestors(parentMeta.ancestors); + old.setLevelNo(parentMeta.levelNo); + old.setCategoryName(entity.getCategoryName().trim()); + if (entity.getSortNo() != null) { + old.setSortNo(entity.getSortNo()); + } + if (entity.getStatus() != null) { + old.setStatus(entity.getStatus()); + } + old.setModified(now); + old.setModifiedBy(userId); + + categoryMap.put(old.getId(), old); + Map> childrenMap = buildChildrenMap(categoryMap.values()); + List changedChildren = new ArrayList<>(); + refreshChildrenLevelAndAncestors(old, childrenMap, changedChildren, now, userId); + + boolean updated = updateById(old); + if (!updated) { + return false; + } + + for (FaqCategory child : changedChildren) { + updateById(child); + } + + return true; + } + + @Override + @Transactional + public boolean removeCategory(BigInteger id) { + if (id == null) { + throw new BusinessException("FAQ分类ID不能为空"); + } + + FaqCategory old = getById(id); + if (old == null) { + throw new BusinessException("FAQ分类不存在"); + } + + checkFaqCollection(old.getCollectionId()); + + if (Boolean.TRUE.equals(old.getIsDefault())) { + throw new BusinessException("默认分类不允许删除"); + } + + QueryWrapper childQuery = QueryWrapper.create() + .eq(FaqCategory::getCollectionId, old.getCollectionId()) + .eq(FaqCategory::getParentId, old.getId()); + long childrenCount = mapper.selectCountByQuery(childQuery); + if (childrenCount > 0) { + throw new BusinessException("该分类下存在子分类,请先处理子分类"); + } + + QueryWrapper faqQuery = QueryWrapper.create() + .eq(FaqItem::getCollectionId, old.getCollectionId()) + .eq(FaqItem::getCategoryId, old.getId()); + long faqCount = faqItemMapper.selectCountByQuery(faqQuery); + if (faqCount > 0) { + throw new BusinessException("该分类下存在FAQ条目,请先处理FAQ条目"); + } + + return removeById(id); + } + + @Override + public List findDescendantIds(BigInteger collectionId, BigInteger categoryId) { + if (collectionId == null || categoryId == null) { + return Collections.emptyList(); + } + + QueryWrapper queryWrapper = QueryWrapper.create() + .eq(FaqCategory::getCollectionId, collectionId) + .orderBy("sort_no asc, id asc"); + List categories = list(queryWrapper); + Map categoryMap = new HashMap<>(); + for (FaqCategory category : categories) { + categoryMap.put(category.getId(), category); + } + + if (!categoryMap.containsKey(categoryId)) { + return Collections.emptyList(); + } + + Set descendantSet = findDescendantSet(categoryMap, categoryId); + return new ArrayList<>(descendantSet); + } + + @Override + public Map buildPathMap(BigInteger collectionId) { + if (collectionId == null) { + return Collections.emptyMap(); + } + QueryWrapper queryWrapper = QueryWrapper.create() + .eq(FaqCategory::getCollectionId, collectionId) + .orderBy("sort_no asc, id asc"); + List categories = list(queryWrapper); + Map categoryMap = new HashMap<>(); + for (FaqCategory category : categories) { + categoryMap.put(category.getId(), category); + } + + Map pathMap = new HashMap<>(); + for (FaqCategory category : categories) { + buildPath(category.getId(), categoryMap, pathMap, new HashSet<>()); + } + return pathMap; + } + + private void backfillNullFaqItemCategory(BigInteger collectionId, BigInteger defaultCategoryId) { + QueryWrapper queryWrapper = QueryWrapper.create() + .eq(FaqItem::getCollectionId, collectionId) + .isNull(FaqItem::getCategoryId); + long emptyCategoryCount = faqItemMapper.selectCountByQuery(queryWrapper); + if (emptyCategoryCount <= 0) { + return; + } + FaqItem faqItem = new FaqItem(); + faqItem.setCategoryId(defaultCategoryId); + faqItem.setModified(new Date()); + faqItem.setModifiedBy(getCurrentUserId()); + faqItemMapper.updateByQuery(faqItem, queryWrapper); + } + + private String buildPath(BigInteger categoryId, + Map categoryMap, + Map pathMap, + Set visiting) { + if (categoryId == null) { + return ""; + } + if (pathMap.containsKey(categoryId)) { + return pathMap.get(categoryId); + } + + FaqCategory category = categoryMap.get(categoryId); + if (category == null) { + return ""; + } + + if (!visiting.add(categoryId)) { + throw new BusinessException("FAQ分类存在循环引用"); + } + + String path; + BigInteger parentId = normalizeParentId(category.getParentId()); + if (ROOT_PARENT_ID.equals(parentId)) { + path = category.getCategoryName(); + } else { + String parentPath = buildPath(parentId, categoryMap, pathMap, visiting); + if (StringUtil.noText(parentPath)) { + path = category.getCategoryName(); + } else { + path = parentPath + " / " + category.getCategoryName(); + } + } + + visiting.remove(categoryId); + pathMap.put(categoryId, path); + return path; + } + + private Set findDescendantSet(Map categoryMap, BigInteger categoryId) { + Map> childrenMap = buildChildrenMap(categoryMap.values()); + Set result = new HashSet<>(); + ArrayDeque queue = new ArrayDeque<>(); + queue.add(categoryId); + + while (!queue.isEmpty()) { + BigInteger currentId = queue.poll(); + if (!result.add(currentId)) { + continue; + } + List children = childrenMap.getOrDefault(currentId, Collections.emptyList()); + for (FaqCategory child : children) { + queue.offer(child.getId()); + } + } + + return result; + } + + private Map> buildChildrenMap(Iterable categories) { + Map> childrenMap = new HashMap<>(); + for (FaqCategory category : categories) { + BigInteger parentId = normalizeParentId(category.getParentId()); + childrenMap.computeIfAbsent(parentId, key -> new ArrayList<>()).add(category); + } + return childrenMap; + } + + private void refreshChildrenLevelAndAncestors(FaqCategory parent, + Map> childrenMap, + List changedChildren, + Date modified, + BigInteger modifiedBy) { + List children = childrenMap.getOrDefault(parent.getId(), Collections.emptyList()); + for (FaqCategory child : children) { + int nextLevel = parent.getLevelNo() + 1; + if (nextLevel > 3) { + throw new BusinessException("最多支持三级分类"); + } + child.setLevelNo(nextLevel); + child.setAncestors(parent.getAncestors() + "," + parent.getId()); + child.setModified(modified); + child.setModifiedBy(modifiedBy); + changedChildren.add(child); + refreshChildrenLevelAndAncestors(child, childrenMap, changedChildren, modified, modifiedBy); + } + } + + private ParentMeta getParentMeta(BigInteger collectionId, BigInteger parentId, BigInteger currentId) { + BigInteger normalizedParentId = normalizeParentId(parentId); + if (ROOT_PARENT_ID.equals(normalizedParentId)) { + return new ParentMeta("0", 1); + } + + FaqCategory parent = getById(normalizedParentId); + if (parent == null || !collectionId.equals(parent.getCollectionId())) { + throw new BusinessException("父分类不存在"); + } + if (currentId != null && currentId.equals(parent.getId())) { + throw new BusinessException("父分类不能是自己"); + } + + int level = (parent.getLevelNo() == null ? 0 : parent.getLevelNo()) + 1; + if (level > 3) { + throw new BusinessException("最多支持三级分类"); + } + return new ParentMeta(parent.getAncestors() + "," + parent.getId(), level); + } + + private BigInteger normalizeParentId(BigInteger parentId) { + return parentId == null ? ROOT_PARENT_ID : parentId; + } + + private Integer nextSortNo(BigInteger collectionId, BigInteger parentId) { + QueryWrapper queryWrapper = QueryWrapper.create() + .eq(FaqCategory::getCollectionId, collectionId) + .eq(FaqCategory::getParentId, parentId) + .orderBy("sort_no desc, id desc"); + List siblings = list(queryWrapper); + if (siblings == null || siblings.isEmpty() || siblings.get(0).getSortNo() == null) { + return 0; + } + return siblings.get(0).getSortNo() + 1; + } + + private void validateParentNotDefault(BigInteger collectionId, BigInteger parentId) { + if (parentId == null || ROOT_PARENT_ID.equals(parentId)) { + return; + } + FaqCategory parent = getById(parentId); + if (parent == null || !collectionId.equals(parent.getCollectionId())) { + return; + } + if (Boolean.TRUE.equals(parent.getIsDefault())) { + throw new BusinessException("默认分类不允许创建子分类"); + } + } + + private DocumentCollection checkFaqCollection(BigInteger collectionId) { + if (collectionId == null) { + throw new BusinessException("知识库ID不能为空"); + } + DocumentCollection collection = documentCollectionService.getById(collectionId); + if (collection == null) { + throw new BusinessException("知识库不存在"); + } + if (!collection.isFaqCollection()) { + throw new BusinessException("当前知识库不是FAQ类型"); + } + return collection; + } + + private BigInteger getCurrentUserId() { + if (!StpUtil.isLogin()) { + return null; + } + return BigInteger.valueOf(StpUtil.getLoginIdAsLong()); + } + + private static class ParentMeta { + private final String ancestors; + private final int levelNo; + + private ParentMeta(String ancestors, int levelNo) { + this.ancestors = ancestors; + this.levelNo = levelNo; + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/FaqItemServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/FaqItemServiceImpl.java index c950019..176e7fb 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/FaqItemServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/FaqItemServiceImpl.java @@ -19,6 +19,7 @@ import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.FaqItem; import tech.easyflow.ai.entity.Model; import tech.easyflow.ai.mapper.FaqItemMapper; +import tech.easyflow.ai.service.FaqCategoryService; import tech.easyflow.ai.service.DocumentCollectionService; import tech.easyflow.ai.service.FaqItemService; import tech.easyflow.ai.service.ModelService; @@ -47,6 +48,9 @@ public class FaqItemServiceImpl extends ServiceImpl impl @Resource private ModelService modelService; + @Resource + private FaqCategoryService faqCategoryService; + @Autowired private SearcherFactory searcherFactory; @@ -54,6 +58,9 @@ public class FaqItemServiceImpl extends ServiceImpl impl @Transactional public boolean saveFaqItem(FaqItem entity) { checkAndNormalize(entity, true); + DocumentCollection collection = getFaqCollection(entity.getCollectionId()); + entity.setCategoryId(faqCategoryService.ensureFaqItemCategory(entity.getCollectionId(), entity.getCategoryId())); + Date now = new Date(); BigInteger userId = getCurrentUserId(); entity.setCreated(now); @@ -69,7 +76,6 @@ public class FaqItemServiceImpl extends ServiceImpl impl return false; } - DocumentCollection collection = getFaqCollection(entity.getCollectionId()); storeToVector(collection, entity, false); return true; } @@ -89,6 +95,8 @@ public class FaqItemServiceImpl extends ServiceImpl impl } checkAndNormalize(entity, false); + DocumentCollection collection = getFaqCollection(old.getCollectionId()); + old.setCategoryId(faqCategoryService.ensureFaqItemCategory(old.getCollectionId(), entity.getCategoryId())); old.setQuestion(entity.getQuestion()); old.setAnswerHtml(entity.getAnswerHtml()); old.setAnswerText(entity.getAnswerText()); @@ -104,7 +112,6 @@ public class FaqItemServiceImpl extends ServiceImpl impl return false; } - DocumentCollection collection = getFaqCollection(old.getCollectionId()); storeToVector(collection, old, true); return true; } @@ -226,6 +233,7 @@ public class FaqItemServiceImpl extends ServiceImpl impl Map metadata = new HashMap<>(); metadata.put("question", entity.getQuestion()); metadata.put("answerText", entity.getAnswerText()); + metadata.put("categoryId", entity.getCategoryId()); doc.setMetadataMap(metadata); return doc; } diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json b/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json index 77b3707..2bed025 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json @@ -81,6 +81,28 @@ }, "faq": { "faqList": "FAQ List", + "allFaq": "All FAQ", + "category": "Category", + "categoryPlaceholder": "Select a category (empty means default category)", + "categoryTree": "Category Tree", + "categoryPath": "Category Path", + "categoryName": "Category Name", + "categoryNamePlaceholder": "Please input category name", + "parentCategory": "Parent Category", + "parentCategoryPlaceholder": "Please select parent category", + "rootCategory": "Root Category", + "sortNo": "Sort No", + "addCategory": "Add Category", + "addSiblingCategory": "Add Sibling Category", + "addChildCategory": "Add Child Category", + "moveUp": "Move Up", + "moveDown": "Move Down", + "promote": "Promote", + "demote": "Demote", + "editCategory": "Edit Category", + "maxLevelTip": "Maximum 3 levels are supported", + "defaultCategoryChildForbidden": "Default category cannot have child categories", + "defaultCategoryDeleteForbidden": "Default category cannot be deleted", "question": "Question", "answer": "Answer", "questionPlaceholder": "Please input question", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json index 2c2eb28..924f6b9 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json @@ -81,6 +81,28 @@ }, "faq": { "faqList": "FAQ列表", + "allFaq": "全部FAQ", + "category": "分类", + "categoryPlaceholder": "请选择分类(不选则自动归入默认分类)", + "categoryTree": "分类树", + "categoryPath": "分类路径", + "categoryName": "分类名称", + "categoryNamePlaceholder": "请输入分类名称", + "parentCategory": "父分类", + "parentCategoryPlaceholder": "请选择父分类", + "rootCategory": "根分类", + "sortNo": "排序", + "addCategory": "新增分类", + "addSiblingCategory": "新增同级分类", + "addChildCategory": "新增子分类", + "moveUp": "上移", + "moveDown": "下移", + "promote": "升级", + "demote": "降级", + "editCategory": "编辑分类", + "maxLevelTip": "最多支持三级分类", + "defaultCategoryChildForbidden": "默认分类不允许创建子分类", + "defaultCategoryDeleteForbidden": "默认分类不允许删除", "question": "问题", "answer": "答案", "questionPlaceholder": "请输入问题", diff --git a/easyflow-ui-admin/app/src/views/ai/documentCollection/Document.vue b/easyflow-ui-admin/app/src/views/ai/documentCollection/Document.vue index 5525735..111dea6 100644 --- a/easyflow-ui-admin/app/src/views/ai/documentCollection/Document.vue +++ b/easyflow-ui-admin/app/src/views/ai/documentCollection/Document.vue @@ -1,19 +1,17 @@ + + diff --git a/easyflow-ui-admin/app/src/views/ai/documentCollection/FaqEditDialog.vue b/easyflow-ui-admin/app/src/views/ai/documentCollection/FaqEditDialog.vue index e1aac22..92efce1 100644 --- a/easyflow-ui-admin/app/src/views/ai/documentCollection/FaqEditDialog.vue +++ b/easyflow-ui-admin/app/src/views/ai/documentCollection/FaqEditDialog.vue @@ -1,13 +1,22 @@