categoryIds;
+ /** 难度 */
+ private String difficulty;
+
+}
diff --git a/src/main/java/com/qingqiu/interview/dto/QuestionPageParams.java b/src/main/java/com/qingqiu/interview/dto/QuestionPageParams.java
index d62ec7c..f5d8528 100755
--- a/src/main/java/com/qingqiu/interview/dto/QuestionPageParams.java
+++ b/src/main/java/com/qingqiu/interview/dto/QuestionPageParams.java
@@ -8,7 +8,9 @@ import lombok.experimental.Accessors;
@Data
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
-public class QuestionPageParams extends PageBaseParams{
+public class QuestionPageParams extends PageBaseParams {
private String content;
+
+ private Long categoryId;
}
diff --git a/src/main/java/com/qingqiu/interview/entity/Question.java b/src/main/java/com/qingqiu/interview/entity/Question.java
index a0d7e29..985fe23 100755
--- a/src/main/java/com/qingqiu/interview/entity/Question.java
+++ b/src/main/java/com/qingqiu/interview/entity/Question.java
@@ -19,8 +19,11 @@ public class Question {
@TableField("content")
private String content;
- @TableField("category")
- private String category;
+ @TableField("category_id")
+ private Long categoryId;
+
+ @TableField("category_name")
+ private String categoryName;
@TableField("difficulty")
private String difficulty;
diff --git a/src/main/java/com/qingqiu/interview/entity/QuestionCategory.java b/src/main/java/com/qingqiu/interview/entity/QuestionCategory.java
index 4dcf849..bfd90c3 100755
--- a/src/main/java/com/qingqiu/interview/entity/QuestionCategory.java
+++ b/src/main/java/com/qingqiu/interview/entity/QuestionCategory.java
@@ -1,15 +1,14 @@
package com.qingqiu.interview.entity;
import com.baomidou.mybatisplus.annotation.*;
-
-import java.time.LocalDateTime;
-import java.io.Serializable;
-import java.util.List;
-
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+import java.util.List;
+
/**
*
* 题型分类
@@ -26,7 +25,7 @@ public class QuestionCategory implements Serializable {
private static final long serialVersionUID = 1L;
- @TableId(value = "id", type = IdType.AUTO)
+ @TableId(type = IdType.AUTO)
private Long id;
/**
diff --git a/src/main/java/com/qingqiu/interview/mapper/QuestionCategoryMapper.java b/src/main/java/com/qingqiu/interview/mapper/QuestionCategoryMapper.java
index a00648e..2b02a3c 100755
--- a/src/main/java/com/qingqiu/interview/mapper/QuestionCategoryMapper.java
+++ b/src/main/java/com/qingqiu/interview/mapper/QuestionCategoryMapper.java
@@ -2,6 +2,9 @@ package com.qingqiu.interview.mapper;
import com.qingqiu.interview.entity.QuestionCategory;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
/**
*
@@ -12,5 +15,5 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
* @since 2025-09-08
*/
public interface QuestionCategoryMapper extends BaseMapper {
-
+ List batchFindByAncestorIdsUnion(@Param("searchIds") List searchIds);
}
diff --git a/src/main/java/com/qingqiu/interview/mapper/QuestionMapper.java b/src/main/java/com/qingqiu/interview/mapper/QuestionMapper.java
index 019dde9..b5b995e 100755
--- a/src/main/java/com/qingqiu/interview/mapper/QuestionMapper.java
+++ b/src/main/java/com/qingqiu/interview/mapper/QuestionMapper.java
@@ -1,6 +1,9 @@
package com.qingqiu.interview.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.qingqiu.interview.dto.DashboardStatsResponse;
+import com.qingqiu.interview.dto.QuestionPageParams;
import com.qingqiu.interview.entity.Question;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -18,6 +21,9 @@ public interface QuestionMapper extends BaseMapper {
Question selectByContent(@Param("content") String content);
- List countByCategory();
+ List countByCategory();
+
+ Page queryPage(@Param("page") Page page, @Param("params") QuestionPageParams params);
+
}
diff --git a/src/main/java/com/qingqiu/interview/service/IQuestionCategoryService.java b/src/main/java/com/qingqiu/interview/service/IQuestionCategoryService.java
index 16a5f42..6678500 100755
--- a/src/main/java/com/qingqiu/interview/service/IQuestionCategoryService.java
+++ b/src/main/java/com/qingqiu/interview/service/IQuestionCategoryService.java
@@ -1,12 +1,10 @@
package com.qingqiu.interview.service;
-import cn.hutool.db.PageResult;
-import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.IService;
import com.qingqiu.interview.dto.QuestionCategoryDTO;
import com.qingqiu.interview.dto.QuestionCategoryPageParams;
import com.qingqiu.interview.entity.QuestionCategory;
-import com.baomidou.mybatisplus.extension.service.IService;
import java.util.List;
@@ -37,7 +35,7 @@ public interface IQuestionCategoryService extends IService {
/**
* 更新分类
*/
- void updateCategory(Long id, QuestionCategoryDTO dto);
+ void updateCategory(QuestionCategoryDTO dto);
/**
* 删除分类
@@ -64,43 +62,14 @@ public interface IQuestionCategoryService extends IService {
*/
List searchByName(String name);
- /**
- * 获取某个分类的所有子孙分类
- */
- List getAllDescendants(Long parentId);
- /**
- * 批量更新分类状态(包含子孙分类)
- */
- void batchUpdateState(Long parentId, Integer state);
-
- /**
- * 获取分类的完整路径名称
- */
- String getFullPathName(Long categoryId);
/**
* 检查分类名称是否重复
*/
boolean checkNameExists(String name, Long parentId, Long excludeId);
- /**
- * 移动分类(修改父分类)
- */
- void moveCategory(Long id, Long newParentId);
+ List batchFindByAncestorIdsUnion(List searchIds);
- /**
- * 获取指定层级的分类
- */
- List getCategoriesByLevel(Integer level);
- /**
- * 获取启用的分类树
- */
- List getEnabledTreeList();
-
- /**
- * 根据父ID获取子分类
- */
- List getChildrenByParentId(Long parentId);
}
diff --git a/src/main/java/com/qingqiu/interview/service/QuestionClassificationService.java b/src/main/java/com/qingqiu/interview/service/QuestionClassificationService.java
index d7e2ac2..f4881fb 100755
--- a/src/main/java/com/qingqiu/interview/service/QuestionClassificationService.java
+++ b/src/main/java/com/qingqiu/interview/service/QuestionClassificationService.java
@@ -82,7 +82,7 @@ public class QuestionClassificationService {
for (JsonNode questionNode : questionsNode) {
Question question = new Question()
.setContent(getTextValue(questionNode, "content"))
- .setCategory(getTextValue(questionNode, "category"))
+ .setCategoryName(getTextValue(questionNode, "category"))
.setDifficulty(getTextValue(questionNode, "difficulty"))
.setTags(getTextValue(questionNode, "tags"));
@@ -112,7 +112,7 @@ public class QuestionClassificationService {
private boolean isValidQuestion(Question question) {
return question.getContent() != null && !question.getContent().trim().isEmpty()
- && question.getCategory() != null && !question.getCategory().trim().isEmpty();
+ && question.getCategoryName() != null && !question.getCategoryName().trim().isEmpty();
}
private List fallbackParsing(String content) {
@@ -126,7 +126,7 @@ public class QuestionClassificationService {
if (!line.isEmpty() && line.length() > 10) { // 过滤太短的内容
Question question = new Question()
.setContent(line)
- .setCategory("未分类")
+ .setCategoryName("未分类")
.setDifficulty("Medium")
.setTags("待分类");
questions.add(question);
diff --git a/src/main/java/com/qingqiu/interview/service/QuestionService.java b/src/main/java/com/qingqiu/interview/service/QuestionService.java
index eae9abc..670a6d4 100755
--- a/src/main/java/com/qingqiu/interview/service/QuestionService.java
+++ b/src/main/java/com/qingqiu/interview/service/QuestionService.java
@@ -1,177 +1,30 @@
package com.qingqiu.interview.service;
-import cn.hutool.core.collection.CollectionUtil;
-import com.alibaba.fastjson2.JSONArray;
-import com.alibaba.fastjson2.JSONObject;
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.qingqiu.interview.dto.QuestionOptionsDTO;
import com.qingqiu.interview.dto.QuestionPageParams;
import com.qingqiu.interview.entity.Question;
-import com.qingqiu.interview.mapper.QuestionMapper;
-import com.qingqiu.interview.service.llm.LlmService;
-import com.qingqiu.interview.service.parser.DocumentParser;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.apache.commons.lang3.StringUtils;
-import org.jetbrains.annotations.NotNull;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
+import com.qingqiu.interview.entity.QuestionCategory;
+import com.qingqiu.interview.vo.QuestionAndCategoryTreeListVO;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
-@Slf4j
-@Service
-@RequiredArgsConstructor
-public class QuestionService {
+public interface QuestionService extends IService {
- private final QuestionMapper questionMapper;
- private final QuestionClassificationService classificationService;
- private final List documentParserList; // This will be injected by Spring
- private final LlmService llmService;
+ Page getQuestionPage(QuestionPageParams params);
- /**
- * 分页查询题库
- */
- public Page getQuestionPage(QuestionPageParams params) {
- log.info("分页查询题库,当前页: {}, 每页数量: {}", params.getCurrent(), params.getSize());
- return questionMapper.selectPage(
- Page.of(params.getCurrent(), params.getSize()),
- new LambdaQueryWrapper()
- .like(StringUtils.isNotBlank(params.getContent()), Question::getContent, params.getContent())
- .orderByDesc(Question::getCreatedTime)
- );
- }
+ void addQuestion(Question question);
- /**
- * 新增题目,并进行重复校验
- */
- public void addQuestion(Question question) {
- validateQuestion(question.getContent(), null);
- log.info("新增题目: {}", question.getContent());
- questionMapper.insert(question);
- }
+ void updateQuestion(Question question);
- /**
- * 更新题目,并进行重复校验
- */
- public void updateQuestion(Question question) {
- validateQuestion(question.getContent(), question.getId());
- log.info("更新题目ID: {}", question.getId());
- questionMapper.updateById(question);
- }
+ void deleteQuestion(Long id);
- /**
- * 删除题目
- */
- public void deleteQuestion(Long id) {
- log.info("删除题目ID: {}", id);
- questionMapper.deleteById(id);
- }
+ void importQuestionsFromFile(MultipartFile file) throws IOException;
- /**
- * AI批量导入题库,并进行去重
- */
- public void importQuestionsFromFile(MultipartFile file) throws IOException {
- log.info("开始从文件导入题库: {}", file.getOriginalFilename());
- String fileExtension = getFileExtension(file.getOriginalFilename());
- DocumentParser parser = documentParserList.stream()
- .filter(p -> p.getSupportedType().equals(fileExtension))
- .findFirst()
- .orElseThrow(() -> new IllegalArgumentException("不支持的文件类型: " + fileExtension));
+ void useAiCheckQuestionData();
- String content = parser.parse(file.getInputStream());
- List questionsFromAi = classificationService.classifyQuestions(content);
-
- int newQuestionsCount = 0;
- for (Question question : questionsFromAi) {
- try {
- validateQuestion(question.getContent(), null);
- questionMapper.insert(question);
- newQuestionsCount++;
- } catch (IllegalArgumentException e) {
- log.warn("跳过重复题目: {}", question.getContent());
- }
- }
- log.info("成功导入 {} 个新题目,跳过 {} 个重复题目。", newQuestionsCount, questionsFromAi.size() - newQuestionsCount);
- }
-
- /**
- * 调用AI检查题库中的数据是否重复
- */
- @Transactional(rollbackFor = Exception.class)
- public void useAiCheckQuestionData() {
- // 查询数据库
- List questions = questionMapper.selectList(
- new LambdaQueryWrapper()
- .orderByDesc(Question::getCreatedTime)
- );
- // 组装prompt
- if (CollectionUtil.isEmpty(questions)) {
- return;
- }
- String prompt = getPrompt(questions);
- log.info("发送内容: {}", prompt);
- // 验证token上下文长度
- Integer promptTokens = llmService.getPromptTokens(prompt);
- log.info("当前prompt长度: {}", promptTokens);
- String chat = llmService.chat(prompt);
- // 调用AI
- log.info("AI返回内容: {}", chat);
- JSONObject parse = JSONObject.parse(chat);
- JSONArray questionsIds = parse.getJSONArray("questions");
- List list = questionsIds.toList(Long.class);
- questionMapper.delete(
- new LambdaQueryWrapper()
- .notIn(Question::getId, list)
- );
- }
-
- @NotNull
- private static String getPrompt(List questions) {
- JSONArray jsonArray = new JSONArray();
- for (Question question : questions) {
- JSONObject jsonObject = new JSONObject();
- jsonObject.put("id", question.getId());
- jsonObject.put("content", question.getContent());
- jsonArray.add(jsonObject);
- }
- JSONObject jsonObject = new JSONObject();
- jsonObject.put("data", jsonArray);
- return String.format("""
- 请对以下数据进行重复校验,如果题目内容相似,请只保留1条数据,并返回对应数据的id。请严格按照以下JSON格式返回结果:
-
- {
- "questions": [1, 2, 3, .....]
- }
-
- 分类规则:
- 1. 只返回JSON,不要其他解释文字
- 2. 请严格按照API接口形式返回,不要返回任何额外的文字内容,包括'```json```'!!!!
- 3. 请严格按照网络接口的形式返回JSON数据!!!
- 数据如下:
- %s
- """, jsonObject.toJSONString());
- }
-
- /**
- * 校验题目内容是否重复
- *
- * @param content 题目内容
- * @param currentId 当前题目ID,更新时传入,用于排除自身
- */
- private void validateQuestion(String content, Long currentId) {
- Question existingQuestion = questionMapper.selectByContent(content);
- if (existingQuestion != null && (currentId == null || !existingQuestion.getId().equals(currentId))) {
- throw new IllegalArgumentException("题目内容已存在,请勿重复添加。");
- }
- }
-
- private String getFileExtension(String fileName) {
- if (fileName == null || fileName.lastIndexOf('.') == -1) {
- return "";
- }
- return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
- }
+ List getTreeListCategory(QuestionOptionsDTO dto);
}
diff --git a/src/main/java/com/qingqiu/interview/service/impl/QuestionCategoryServiceImpl.java b/src/main/java/com/qingqiu/interview/service/impl/QuestionCategoryServiceImpl.java
index 22b7468..d07674c 100755
--- a/src/main/java/com/qingqiu/interview/service/impl/QuestionCategoryServiceImpl.java
+++ b/src/main/java/com/qingqiu/interview/service/impl/QuestionCategoryServiceImpl.java
@@ -1,7 +1,6 @@
package com.qingqiu.interview.service.impl;
import cn.hutool.core.collection.CollectionUtil;
-import cn.hutool.db.PageResult;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -9,11 +8,16 @@ import com.qingqiu.interview.common.constants.CommonConstant;
import com.qingqiu.interview.common.enums.CommonStateEnum;
import com.qingqiu.interview.dto.QuestionCategoryDTO;
import com.qingqiu.interview.dto.QuestionCategoryPageParams;
+import com.qingqiu.interview.entity.Question;
import com.qingqiu.interview.entity.QuestionCategory;
import com.qingqiu.interview.mapper.QuestionCategoryMapper;
import com.qingqiu.interview.service.IQuestionCategoryService;
+import com.qingqiu.interview.service.QuestionService;
+import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -32,7 +36,11 @@ import java.util.stream.Collectors;
*/
@Slf4j
@Service
+@RequiredArgsConstructor(onConstructor_ = {@Autowired, @Lazy})
public class QuestionCategoryServiceImpl extends ServiceImpl implements IQuestionCategoryService {
+
+ private final QuestionService questionService;
+
@Override
public List getTreeList() {
List allCategories = getAllValidCategories();
@@ -76,21 +84,21 @@ public class QuestionCategoryServiceImpl extends ServiceImpl categoryIdsToDelete = getAllCategoryIdsToDelete(id);
+
+ if (CollectionUtil.isEmpty(categoryIdsToDelete)) {
+ return;
}
- checkChildrenExists(id);
- removeById(id);
+ // 2. 删除所有相关分类
+ this.removeByIds(categoryIdsToDelete);
- log.info("删除分类成功:{}", id);
+ // 3. 删除关联的题目数据
+ questionService.remove(
+ new LambdaQueryWrapper()
+ .in(Question::getCategoryId, categoryIdsToDelete)
+ );
}
@Override
@@ -173,42 +187,6 @@ public class QuestionCategoryServiceImpl extends ServiceImpl getAllDescendants(Long parentId) {
- List allCategories = getAllValidCategories();
- return findDescendants(allCategories, parentId);
- }
-
- @Override
- @Transactional(rollbackFor = Exception.class)
- public void batchUpdateState(Long parentId, Integer state) {
- List descendants = getAllDescendants(parentId);
- descendants.forEach(category -> {
- category.setState(state);
- category.setUpdatedTime(LocalDateTime.now());
- });
-
- updateBatchById(descendants);
- log.info("批量更新分类状态成功:parentId={}, state={}, count={}", parentId, state, descendants.size());
- }
-
- @Override
- public String getFullPathName(Long categoryId) {
- List allCategories = getAllValidCategories();
- Map categoryMap = allCategories.stream()
- .collect(Collectors.toMap(QuestionCategory::getId, category -> category));
-
- List pathNames = new ArrayList<>();
- QuestionCategory current = categoryMap.get(categoryId);
-
- while (current != null) {
- pathNames.add(current.getName());
- current = categoryMap.get(current.getParentId());
- }
-
- Collections.reverse(pathNames);
- return String.join("/", pathNames);
- }
@Override
public boolean checkNameExists(String name, Long parentId, Long excludeId) {
@@ -221,81 +199,22 @@ public class QuestionCategoryServiceImpl extends ServiceImpl batchFindByAncestorIdsUnion(List searchIds) {
+ return baseMapper.batchFindByAncestorIdsUnion(searchIds);
}
- @Override
- public List getCategoriesByLevel(Integer level) {
- List allCategories = getAllValidCategories();
-
- return allCategories.stream()
- .filter(category -> level.equals(category.getLevel()))
- .sorted(Comparator.comparingInt(QuestionCategory::getSort))
- .collect(Collectors.toList());
- }
-
- @Override
- public List getEnabledTreeList() {
- List allCategories = getAllValidCategories();
-
- List enabledCategories = allCategories.stream()
- .filter(category -> CommonStateEnum.ENABLED.getCode().equals(category.getState()))
- .collect(Collectors.toList());
-
- return buildCategoryTree(enabledCategories);
- }
-
- @Override
- public List getChildrenByParentId(Long parentId) {
- List allCategories = getAllValidCategories();
-
- return allCategories.stream()
- .filter(category -> parentId.equals(category.getParentId()))
- .sorted(Comparator.comparingInt(QuestionCategory::getSort))
- .collect(Collectors.toList());
- }
// ============ 私有方法 ============
private List getAllValidCategories() {
- LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
- wrapper.eq(QuestionCategory::getDeleted, CommonConstant.ZERO)
- .orderByAsc(QuestionCategory::getSort);
- return list(wrapper);
+// LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+// wrapper.eq(QuestionCategory::getDeleted, CommonConstant.ZERO)
+// .orderByAsc(QuestionCategory::getSort);
+ return list(
+ new LambdaQueryWrapper()
+ .orderByDesc(QuestionCategory::getSort)
+ .orderByDesc(QuestionCategory::getCreatedTime)
+ );
}
private List buildCategoryTree(List categories) {
@@ -361,16 +280,6 @@ public class QuestionCategoryServiceImpl extends ServiceImpl allCategories = getAllValidCategories();
- boolean hasChildren = allCategories.stream()
- .anyMatch(category -> parentId.equals(category.getParentId()));
-
- if (hasChildren) {
- throw new RuntimeException("存在子分类,无法删除");
- }
- }
-
private List findDescendants(List allCategories, Long parentId) {
List descendants = new ArrayList<>();
@@ -384,4 +293,29 @@ public class QuestionCategoryServiceImpl extends ServiceImpl getAllCategoryIdsToDelete(Long parentId) {
+ List result = new ArrayList<>();
+ result.add(parentId);
+
+ // 查找直接子分类
+ List children = this.list(
+ new LambdaQueryWrapper()
+ .eq(QuestionCategory::getParentId, parentId)
+ );
+
+ // 递归查找所有子分类
+ for (QuestionCategory child : children) {
+ result.addAll(getAllCategoryIdsToDelete(child.getId()));
+ }
+
+ return result;
+ }
}
diff --git a/src/main/java/com/qingqiu/interview/service/impl/QuestionServiceImpl.java b/src/main/java/com/qingqiu/interview/service/impl/QuestionServiceImpl.java
new file mode 100644
index 0000000..69241be
--- /dev/null
+++ b/src/main/java/com/qingqiu/interview/service/impl/QuestionServiceImpl.java
@@ -0,0 +1,382 @@
+package com.qingqiu.interview.service.impl;
+
+import cn.hutool.core.collection.CollectionUtil;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.qingqiu.interview.ai.enums.LLMProvider;
+import com.qingqiu.interview.common.constants.CommonConstant;
+import com.qingqiu.interview.common.utils.TreeUtil;
+import com.qingqiu.interview.dto.QuestionOptionsDTO;
+import com.qingqiu.interview.dto.QuestionPageParams;
+import com.qingqiu.interview.entity.Question;
+import com.qingqiu.interview.entity.QuestionCategory;
+import com.qingqiu.interview.mapper.QuestionMapper;
+import com.qingqiu.interview.service.IQuestionCategoryService;
+import com.qingqiu.interview.service.QuestionClassificationService;
+import com.qingqiu.interview.service.QuestionService;
+import com.qingqiu.interview.service.llm.LlmService;
+import com.qingqiu.interview.service.parser.DocumentParser;
+import com.qingqiu.interview.vo.QuestionAndCategoryTreeListVO;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.jetbrains.annotations.NotNull;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor(onConstructor_ = {@Autowired, @Lazy})
+public class QuestionServiceImpl extends ServiceImpl implements QuestionService {
+ private final QuestionMapper questionMapper;
+ private final QuestionClassificationService classificationService;
+ private final List documentParserList; // This will be injected by Spring
+ private final LlmService llmService;
+ private final IQuestionCategoryService questionCategoryService;
+
+ /**
+ * 分页查询题库
+ */
+ @Override
+ public Page getQuestionPage(QuestionPageParams params) {
+ log.info("分页查询题库,当前页: {}, 每页数量: {}", params.getCurrent(), params.getSize());
+ return questionMapper.queryPage(
+ Page.of(params.getCurrent(), params.getSize()),
+ params
+ );
+ }
+
+ /**
+ * 新增题目,并进行重复校验
+ */
+ @Override
+ public void addQuestion(Question question) {
+ validateQuestion(question.getContent(), null);
+ log.info("新增题目: {}", question.getContent());
+ questionMapper.insert(question);
+ }
+
+ /**
+ * 更新题目,并进行重复校验
+ */
+ @Override
+ public void updateQuestion(Question question) {
+ validateQuestion(question.getContent(), question.getId());
+ log.info("更新题目ID: {}", question.getId());
+ questionMapper.updateById(question);
+ }
+
+ /**
+ * 删除题目
+ */
+ @Override
+ public void deleteQuestion(Long id) {
+ log.info("删除题目ID: {}", id);
+ questionMapper.deleteById(id);
+ }
+
+ /**
+ * AI批量导入题库,并进行去重
+ */
+ @Override
+ public void importQuestionsFromFile(MultipartFile file) throws IOException {
+ log.info("开始从文件导入题库: {}", file.getOriginalFilename());
+ String fileExtension = getFileExtension(file.getOriginalFilename());
+ DocumentParser parser = documentParserList.stream()
+ .filter(p -> p.getSupportedType().equals(fileExtension))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("不支持的文件类型: " + fileExtension));
+
+ String content = parser.parse(file.getInputStream());
+ List questionsFromAi = classificationService.classifyQuestions(content);
+
+ int newQuestionsCount = 0;
+ for (Question question : questionsFromAi) {
+ try {
+ validateQuestion(question.getContent(), null);
+ questionMapper.insert(question);
+ newQuestionsCount++;
+ } catch (IllegalArgumentException e) {
+ log.warn("跳过重复题目: {}", question.getContent());
+ }
+ }
+ log.info("成功导入 {} 个新题目,跳过 {} 个重复题目。", newQuestionsCount, questionsFromAi.size() - newQuestionsCount);
+ }
+
+ /**
+ * 调用AI检查题库中的数据是否重复
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void useAiCheckQuestionData() {
+ // 查询数据库
+ List questions = questionMapper.selectList(
+ new LambdaQueryWrapper()
+ .orderByDesc(Question::getCreatedTime)
+ );
+ // 组装prompt
+ if (CollectionUtil.isEmpty(questions)) {
+ return;
+ }
+ String prompt = getPrompt(questions);
+ log.info("发送内容: {}", prompt);
+ // 验证token上下文长度
+ Integer promptTokens = llmService.getPromptTokens(prompt);
+ log.info("当前prompt长度: {}", promptTokens);
+ String chat = llmService.chat(prompt, LLMProvider.DEEPSEEK);
+ // 调用AI
+ log.info("AI返回内容: {}", chat);
+ JSONObject parse = JSONObject.parse(chat);
+ JSONArray questionsIds = parse.getJSONArray("questions");
+ List list = questionsIds.toList(Long.class);
+ questionMapper.delete(
+ new LambdaQueryWrapper()
+ .notIn(Question::getId, list)
+ );
+ }
+
+
+ @NotNull
+ private static String getPrompt(List questions) {
+ JSONArray jsonArray = new JSONArray();
+ for (Question question : questions) {
+ JSONObject jsonObject = new JSONObject();
+ jsonObject.put("id", question.getId());
+ jsonObject.put("content", question.getContent());
+ jsonArray.add(jsonObject);
+ }
+ JSONObject jsonObject = new JSONObject();
+ jsonObject.put("data", jsonArray);
+ return String.format("""
+ 你是一个数据清洗与去重专家。你的任务是从以下文本列表中,识别出语义相同或高度相似的条目,并为每一组相似条目筛选出一个最规范、最简洁的代表性版本。
+
+ 【去重规则】
+ 1. **语义核心优先**:忽略无关的修饰词、标点符号、后缀(如“:2”)、大小写和空格。
+ 2. **合并同类项**:将表达同一主题或问题的文本归为一组。
+ 3. **选择标准**:从每一组中,选出那个最完整、最简洁、且没有多余符号(如序号、特殊后缀)的版本作为代表。如果两个版本质量相当,优先选择更短的那个。
+ 4. **保留原意**:确保选出的代表版本没有改变原文本的核心含义。
+ 请按照下述格式返回,已被剔除掉的数据无需返回
+ {
+ "questions": [1, 2, 3, .....]
+ }
+ 分类规则:
+ 1. 只返回JSON,不要其他解释文字
+ 2. 请严格按照API接口形式返回,不要返回任何额外的文字内容,包括'```json```'!!!!
+ 3. 请严格按照网络接口的形式返回JSON数据!!!
+ 【请处理以下数据列表】:
+ %s
+ """, jsonObject.toJSONString());
+ }
+
+ /**
+ * 校验题目内容是否重复
+ *
+ * @param content 题目内容
+ * @param currentId 当前题目ID,更新时传入,用于排除自身
+ */
+ private void validateQuestion(String content, Long currentId) {
+ Question existingQuestion = questionMapper.selectByContent(content);
+ if (existingQuestion != null && (!existingQuestion.getId().equals(currentId))) {
+ throw new IllegalArgumentException("题目内容已存在,请勿重复添加。");
+ }
+ }
+
+ private String getFileExtension(String fileName) {
+ if (fileName == null || fileName.lastIndexOf('.') == -1) {
+ return "";
+ }
+ return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
+ }
+
+ @Override
+ public List getTreeListCategory(QuestionOptionsDTO dto) {
+ if (StringUtils.isNoneBlank(dto.getDifficulty()) && dto.getDifficulty().equals("ALL")) {
+ dto.setDifficulty(null);
+ }
+
+ // 获取分类树列表
+ List treeList = questionCategoryService.getTreeList();
+ List questionCategories = new ArrayList<>();
+
+ if (CollectionUtil.isNotEmpty(dto.getCategoryIds())) {
+ questionCategories = questionCategoryService.batchFindByAncestorIdsUnion(dto.getCategoryIds());
+ if (CollectionUtil.isNotEmpty(questionCategories)) {
+ treeList = TreeUtil.buildTree(
+ questionCategories,
+ QuestionCategory::getId,
+ QuestionCategory::getParentId,
+ QuestionCategory::getChildren,
+ CommonConstant.ROOT_PARENT_ID
+ );
+ }
+ }
+
+ // 获取所有题目列表
+ List questionList = list(
+ new LambdaQueryWrapper()
+ .in(CollectionUtil.isNotEmpty(dto.getCategoryIds()), Question::getCategoryId,
+ dto.getCategoryIds())
+ .eq(StringUtils.isNotBlank(dto.getDifficulty()), Question::getDifficulty, dto.getDifficulty())
+ .eq(Question::getDeleted, 0)
+ );
+
+ // 转换为VO对象并整合题目数据
+ List voList = convertToVOListWithQuestions(treeList, questionList);
+
+ // 设置根节点的题目总数
+ if (CollectionUtil.isNotEmpty(voList)) {
+ Integer i = calcCount(voList);
+ log.info("根节点题目总数: {}", i);
+ QuestionAndCategoryTreeListVO rootVO = new QuestionAndCategoryTreeListVO();
+ rootVO.setId(0L);
+ rootVO.setName("全部题目");
+ rootVO.setType("root");
+ rootVO.setChildren(voList);
+ rootVO.setCount(i);
+ return List.of(rootVO);
+ }
+
+ return voList;
+ }
+
+ /**
+ * 将QuestionCategory列表转换为QuestionAndCategoryTreeListVO列表,并整合题目数据
+ *
+ * @param categoryList 分类列表
+ * @param questionList 题目列表
+ * @return QuestionAndCategoryTreeListVO列表
+ */
+ private List convertToVOListWithQuestions(
+ List categoryList,
+ List questionList) {
+
+ if (CollectionUtil.isEmpty(categoryList)) {
+ return List.of();
+ }
+
+ // 按分类ID分组题目数据
+ Map> questionsByCategoryId = questionList.stream()
+ .filter(Objects::nonNull)
+ .filter(question -> question.getCategoryId() != null)
+ .collect(Collectors.groupingBy(Question::getCategoryId));
+
+ return categoryList.stream()
+ .map(category -> convertToVOWithQuestions(category, questionsByCategoryId))
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * 将单个QuestionCategory转换为QuestionAndCategoryTreeListVO,并整合题目数据
+ *
+ * @param category 分类对象
+ * @param questionsByCategoryId 按分类ID分组的题目数据
+ * @return QuestionAndCategoryTreeListVO对象
+ */
+ private QuestionAndCategoryTreeListVO convertToVOWithQuestions(
+ QuestionCategory category,
+ Map> questionsByCategoryId) {
+
+ if (category == null) {
+ return null;
+ }
+
+ // 创建VO对象
+ QuestionAndCategoryTreeListVO vo = new QuestionAndCategoryTreeListVO();
+
+ // 复制基本属性
+ vo.setId(category.getId());
+ vo.setName(category.getName());
+ vo.setType("category");
+ vo.setCount(0);
+
+ // 处理子节点(包括子分类和题目)
+ List childrenVOs = new ArrayList<>();
+
+ // 先处理子分类
+ if (CollectionUtil.isNotEmpty(category.getChildren())) {
+ for (QuestionCategory childCategory : category.getChildren()) {
+ QuestionAndCategoryTreeListVO childVO = convertToVOWithQuestions(childCategory, questionsByCategoryId);
+ if (childVO != null) {
+ childrenVOs.add(childVO);
+ }
+ }
+ }
+
+ // 再处理当前分类下的题目
+ List questionsInCategory = questionsByCategoryId.getOrDefault(category.getId(), List.of());
+
+ if (CollectionUtil.isNotEmpty(questionsInCategory)) {
+ for (Question question : questionsInCategory) {
+ QuestionAndCategoryTreeListVO questionVO = convertQuestionToVO(question);
+ if (questionVO != null) {
+ childrenVOs.add(questionVO);
+ }
+ }
+ }
+
+ // 设置子节点
+ if (CollectionUtil.isNotEmpty(childrenVOs)) {
+ vo.setChildren(childrenVOs);
+ }
+
+ return vo;
+ }
+
+ /**
+ * 将Question转换为QuestionAndCategoryTreeListVO
+ *
+ * @param question 题目对象
+ * @return QuestionAndCategoryTreeListVO对象
+ */
+ private QuestionAndCategoryTreeListVO convertQuestionToVO(Question question) {
+ if (question == null) {
+ return null;
+ }
+
+ QuestionAndCategoryTreeListVO vo = new QuestionAndCategoryTreeListVO();
+ vo.setId(question.getId());
+ // 使用题目内容作为名称,可以根据需要修改
+ vo.setName(question.getContent());
+ // 题目下面没有子节点
+ vo.setChildren(List.of());
+ vo.setType("question");
+ vo.setCount(0); // 题目节点没有子节点,count设为0
+
+ return vo;
+ }
+
+ private Integer calcCount(List voList) {
+ Integer count = 0;
+ if (CollectionUtil.isNotEmpty(voList)) {
+ for (QuestionAndCategoryTreeListVO vo : voList) {
+ Integer currCount = 0;
+ if (vo.getType().equals("question")) {
+ count++;
+ currCount++;
+ }
+ if (CollectionUtil.isNotEmpty(vo.getChildren())) {
+ Integer i = calcCount(vo.getChildren());
+ count += i;
+ currCount += i;
+ }
+ vo.setCount(currCount);
+ }
+ }
+ return count;
+ }
+
+}
diff --git a/src/main/java/com/qingqiu/interview/service/llm/LlmService.java b/src/main/java/com/qingqiu/interview/service/llm/LlmService.java
index ebf288d..21cf61a 100755
--- a/src/main/java/com/qingqiu/interview/service/llm/LlmService.java
+++ b/src/main/java/com/qingqiu/interview/service/llm/LlmService.java
@@ -1,5 +1,7 @@
package com.qingqiu.interview.service.llm;
+import com.qingqiu.interview.ai.enums.LLMProvider;
+
public interface LlmService {
@@ -9,6 +11,7 @@ public interface LlmService {
* @return ai回复
*/
String chat(String prompt);
+ String chat(String prompt, LLMProvider provider);
/**
* 与模型进行多轮对话
@@ -18,6 +21,15 @@ public interface LlmService {
*/
String chat(String prompt, String token);
+ /**
+ * 与模型进行多轮对话 指定模型
+ * @param prompt 提示词
+ * @param model 模型名称
+ * @param token 会话token
+ * @return ai回复
+ */
+ String chat(String prompt, String token, LLMProvider provider);
+
Integer getPromptTokens(String prompt);
}
diff --git a/src/main/java/com/qingqiu/interview/service/llm/qwen/QwenService.java b/src/main/java/com/qingqiu/interview/service/llm/qwen/QwenService.java
index 9dacdb2..a9cf224 100755
--- a/src/main/java/com/qingqiu/interview/service/llm/qwen/QwenService.java
+++ b/src/main/java/com/qingqiu/interview/service/llm/qwen/QwenService.java
@@ -7,8 +7,8 @@ import com.alibaba.dashscope.common.Role;
import com.alibaba.dashscope.tokenizers.Tokenizer;
import com.alibaba.dashscope.tokenizers.TokenizerFactory;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.qingqiu.interview.ai.enums.LLMProvider;
import com.qingqiu.interview.ai.factory.AIClientManager;
-import com.qingqiu.interview.common.constants.AIStrategyConstant;
import com.qingqiu.interview.entity.AiSessionLog;
import com.qingqiu.interview.mapper.AiSessionLogMapper;
import com.qingqiu.interview.service.llm.LlmService;
@@ -42,7 +42,7 @@ public class QwenService implements LlmService {
public String chat(String prompt) {
// log.info("开始调用API....");
// long l = System.currentTimeMillis();
- return aiClientManager.getClient(AIStrategyConstant.DEEPSEEK).chatCompletion(prompt);
+ return chat(prompt, LLMProvider.DEEPSEEK);
// GenerationParam param = GenerationParam.builder()
// .model(DEEPSEEK_3) // 可根据需要更换模型
// .messages(Collections.singletonList(createMessage(Role.USER.getValue(), prompt)))
@@ -63,8 +63,52 @@ public class QwenService implements LlmService {
// }
}
+ @Override
+ public String chat(String prompt, LLMProvider provider) {
+ return aiClientManager.getClient(provider).chatCompletion(prompt);
+ }
+
@Override
public String chat(String prompt, String token) {
+ return chat(prompt, token, LLMProvider.DEEPSEEK);
+
+// // 调用AI模型
+// try {
+// log.info("开始调用API....");
+// long l = System.currentTimeMillis();
+// GenerationParam param = GenerationParam.builder()
+// .model(DEEPSEEK_3_1) // 可根据需要更换模型
+// .messages(messages)
+// .resultFormat(GenerationParam.ResultFormat.MESSAGE)
+// .apiKey(apiKey)
+// .build();
+//
+// GenerationResult result = generation.call(param);
+// log.info("调用成功,耗时: {} ms", System.currentTimeMillis() - l);
+// String aiResponse = result.getOutput().getChoices().get(0).getMessage().getContent();
+// log.debug("响应结果: {}", aiResponse);
+// // 存储用户提问
+// AiSessionLog userLog = new AiSessionLog();
+// userLog.setToken(token);
+// userLog.setRole(Role.USER.getValue());
+// userLog.setContent(prompt);
+// aiSessionLogMapper.insert(userLog);
+//
+// // 存储AI回复
+// AiSessionLog aiLog = new AiSessionLog();
+// aiLog.setToken(token);
+// aiLog.setRole(Role.ASSISTANT.getValue());
+// aiLog.setContent(aiResponse);
+// aiSessionLogMapper.insert(aiLog);
+//
+// return aiResponse;
+// } catch (ApiException | NoApiKeyException | InputRequiredException e) {
+// throw new RuntimeException("调用AI服务失败", e);
+// }
+ }
+
+ @Override
+ public String chat(String prompt, String token, LLMProvider provider) {
// 根据token查询会话记录
List aiSessionLogs = aiSessionLogMapper.selectList(
new LambdaQueryWrapper()
@@ -113,7 +157,7 @@ public class QwenService implements LlmService {
createMessage(Role.USER.getValue(), prompt)
);
- String aiResponse = aiClientManager.getClient(AIStrategyConstant.DEEPSEEK).chatCompletion(messages);
+ String aiResponse = aiClientManager.getClient(provider).chatCompletion(messages);
// 存储用户提问
AiSessionLog userLog = new AiSessionLog();
userLog.setToken(token);
@@ -128,40 +172,6 @@ public class QwenService implements LlmService {
aiLog.setContent(aiResponse);
aiSessionLogMapper.insert(aiLog);
return aiResponse;
-
-// // 调用AI模型
-// try {
-// log.info("开始调用API....");
-// long l = System.currentTimeMillis();
-// GenerationParam param = GenerationParam.builder()
-// .model(DEEPSEEK_3_1) // 可根据需要更换模型
-// .messages(messages)
-// .resultFormat(GenerationParam.ResultFormat.MESSAGE)
-// .apiKey(apiKey)
-// .build();
-//
-// GenerationResult result = generation.call(param);
-// log.info("调用成功,耗时: {} ms", System.currentTimeMillis() - l);
-// String aiResponse = result.getOutput().getChoices().get(0).getMessage().getContent();
-// log.debug("响应结果: {}", aiResponse);
-// // 存储用户提问
-// AiSessionLog userLog = new AiSessionLog();
-// userLog.setToken(token);
-// userLog.setRole(Role.USER.getValue());
-// userLog.setContent(prompt);
-// aiSessionLogMapper.insert(userLog);
-//
-// // 存储AI回复
-// AiSessionLog aiLog = new AiSessionLog();
-// aiLog.setToken(token);
-// aiLog.setRole(Role.ASSISTANT.getValue());
-// aiLog.setContent(aiResponse);
-// aiSessionLogMapper.insert(aiLog);
-//
-// return aiResponse;
-// } catch (ApiException | NoApiKeyException | InputRequiredException e) {
-// throw new RuntimeException("调用AI服务失败", e);
-// }
}
/**
diff --git a/src/main/java/com/qingqiu/interview/vo/QuestionAndCategoryTreeListVO.java b/src/main/java/com/qingqiu/interview/vo/QuestionAndCategoryTreeListVO.java
new file mode 100644
index 0000000..d9228ff
--- /dev/null
+++ b/src/main/java/com/qingqiu/interview/vo/QuestionAndCategoryTreeListVO.java
@@ -0,0 +1,33 @@
+package com.qingqiu.interview.vo;
+
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.List;
+
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Accessors(chain = true)
+public class QuestionAndCategoryTreeListVO implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private Long id;
+
+ private String name;
+ /**
+ * category:分类
+ * question:问题
+ */
+ private String type;
+
+ private List children;
+
+ private Integer count;
+
+}
diff --git a/src/main/resources/mapper/QuestionCategoryMapper.xml b/src/main/resources/mapper/QuestionCategoryMapper.xml
index 972cbf7..5f9254d 100755
--- a/src/main/resources/mapper/QuestionCategoryMapper.xml
+++ b/src/main/resources/mapper/QuestionCategoryMapper.xml
@@ -2,4 +2,16 @@
+
diff --git a/src/main/resources/mapper/QuestionMapper.xml b/src/main/resources/mapper/QuestionMapper.xml
index be5f22e..138feda 100755
--- a/src/main/resources/mapper/QuestionMapper.xml
+++ b/src/main/resources/mapper/QuestionMapper.xml
@@ -5,14 +5,14 @@