修改AI面试相关内容

This commit is contained in:
2025-09-17 21:36:09 +08:00
parent 7f24d65d76
commit a384bbfd16
31 changed files with 753 additions and 404 deletions

View File

@@ -5,7 +5,7 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.10-SNAPSHOT</version> <version>3.5.0</version>
<relativePath/> <!-- lookup parent from repository --> <relativePath/> <!-- lookup parent from repository -->
</parent> </parent>
<groupId>com.qingqiu</groupId> <groupId>com.qingqiu</groupId>

View File

@@ -0,0 +1,31 @@
package com.qingqiu.interview.ai.enums;
public enum LLMProvider {
OPEN_AI("openai"),
CLAUDE("claude"),
GEMINI("gemini"),
DEEPSEEK("deepSeek"),
OLLAMA("ollama"),
QWEN("qwen"),
;
private final String code;
LLMProvider(String code) {
this.code = code;
}
public String getCode() {
return code;
}
public static LLMProvider fromCode(String code) {
for (LLMProvider provider : values()) {
if (provider.getCode().equals(code)) {
return provider;
}
}
throw new IllegalArgumentException("Unknown provider: " + code);
}
}

View File

@@ -1,7 +1,11 @@
package com.qingqiu.interview.ai.factory; package com.qingqiu.interview.ai.factory;
import com.qingqiu.interview.ai.enums.LLMProvider;
import com.qingqiu.interview.ai.service.AIClientService; import com.qingqiu.interview.ai.service.AIClientService;
public interface AIClientFactory { public interface AIClientFactory {
AIClientService createAIClient(); AIClientService createAIClient();
// 支持的提供商
LLMProvider getSupportedProvider();
} }

View File

@@ -1,24 +1,32 @@
package com.qingqiu.interview.ai.factory; package com.qingqiu.interview.ai.factory;
import com.qingqiu.interview.ai.enums.LLMProvider;
import com.qingqiu.interview.ai.service.AIClientService; import com.qingqiu.interview.ai.service.AIClientService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service @Service
public class AIClientManager { public class AIClientManager {
private final Map<String, AIClientFactory> factories; private final Map<LLMProvider, AIClientFactory> factories;
public AIClientManager(Map<String, AIClientFactory> factories) { public AIClientManager(List<AIClientFactory> strategies) {
this.factories = factories; this.factories = strategies.stream()
.collect(Collectors.toMap(
AIClientFactory::getSupportedProvider,
Function.identity()
));
} }
public AIClientService getClient(String aiType) { public AIClientService getClient(LLMProvider provider) {
String factoryName = aiType + "ClientFactory"; // String factoryName = aiType + "ClientFactory";
AIClientFactory factory = factories.get(factoryName); AIClientFactory factory = factories.get(provider);
if (factory == null) { if (factory == null) {
throw new IllegalArgumentException("不支持的AI type: " + aiType); throw new IllegalArgumentException("不支持的AI type: " + provider);
} }
return factory.createAIClient(); return factory.createAIClient();
} }

View File

@@ -1,5 +1,6 @@
package com.qingqiu.interview.ai.factory; package com.qingqiu.interview.ai.factory;
import com.qingqiu.interview.ai.enums.LLMProvider;
import com.qingqiu.interview.ai.service.AIClientService; import com.qingqiu.interview.ai.service.AIClientService;
import com.qingqiu.interview.ai.service.impl.DeepSeekClientServiceImpl; import com.qingqiu.interview.ai.service.impl.DeepSeekClientServiceImpl;
import com.qingqiu.interview.common.utils.SpringApplicationContextUtil; import com.qingqiu.interview.common.utils.SpringApplicationContextUtil;
@@ -11,4 +12,9 @@ public class DeepSeekClientFactory implements AIClientFactory{
public AIClientService createAIClient() { public AIClientService createAIClient() {
return SpringApplicationContextUtil.getBean(DeepSeekClientServiceImpl.class); return SpringApplicationContextUtil.getBean(DeepSeekClientServiceImpl.class);
} }
@Override
public LLMProvider getSupportedProvider() {
return LLMProvider.DEEPSEEK;
}
} }

View File

@@ -1,5 +1,6 @@
package com.qingqiu.interview.ai.factory; package com.qingqiu.interview.ai.factory;
import com.qingqiu.interview.ai.enums.LLMProvider;
import com.qingqiu.interview.ai.service.AIClientService; import com.qingqiu.interview.ai.service.AIClientService;
import com.qingqiu.interview.ai.service.impl.QwenClientServiceImpl; import com.qingqiu.interview.ai.service.impl.QwenClientServiceImpl;
import com.qingqiu.interview.common.utils.SpringApplicationContextUtil; import com.qingqiu.interview.common.utils.SpringApplicationContextUtil;
@@ -11,4 +12,9 @@ public class QwenClientFactory implements AIClientFactory{
public AIClientService createAIClient() { public AIClientService createAIClient() {
return SpringApplicationContextUtil.getBean(QwenClientServiceImpl.class); return SpringApplicationContextUtil.getBean(QwenClientServiceImpl.class);
} }
@Override
public LLMProvider getSupportedProvider() {
return LLMProvider.QWEN;
}
} }

View File

@@ -4,6 +4,7 @@ import com.alibaba.dashscope.aigc.generation.Generation;
import com.alibaba.dashscope.aigc.generation.GenerationParam; import com.alibaba.dashscope.aigc.generation.GenerationParam;
import com.alibaba.dashscope.aigc.generation.GenerationResult; import com.alibaba.dashscope.aigc.generation.GenerationResult;
import com.alibaba.dashscope.common.Message; import com.alibaba.dashscope.common.Message;
import com.alibaba.dashscope.common.ResponseFormat;
import com.alibaba.dashscope.exception.ApiException; import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.InputRequiredException; import com.alibaba.dashscope.exception.InputRequiredException;
import com.alibaba.dashscope.exception.NoApiKeyException; import com.alibaba.dashscope.exception.NoApiKeyException;
@@ -42,6 +43,7 @@ public class QwenClientServiceImpl extends AIClientService {
.model(QWEN_PLUS_LATEST) // 可根据需要更换模型 .model(QWEN_PLUS_LATEST) // 可根据需要更换模型
.messages(messages) .messages(messages)
.resultFormat(GenerationParam.ResultFormat.MESSAGE) .resultFormat(GenerationParam.ResultFormat.MESSAGE)
.responseFormat(ResponseFormat.builder().type(ResponseFormat.JSON_OBJECT).build())
.apiKey(apiKey) .apiKey(apiKey)
.build(); .build();

View File

@@ -1,6 +1,9 @@
package com.qingqiu.interview.common.utils; package com.qingqiu.interview.common.utils;
import java.util.*; import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -56,4 +59,5 @@ public class TreeUtil {
} }
} }
} }
} }

View File

@@ -12,7 +12,7 @@ import org.springframework.web.bind.annotation.RestController;
* 仪表盘数据统计接口 * 仪表盘数据统计接口
*/ */
@RestController @RestController
@RequestMapping("/api/v1/dashboard") @RequestMapping("/dashboard")
@RequiredArgsConstructor @RequiredArgsConstructor
public class DashboardController { public class DashboardController {

View File

@@ -1,20 +1,24 @@
package com.qingqiu.interview.controller; package com.qingqiu.interview.controller;
import com.alibaba.fastjson2.JSONObject;
import com.qingqiu.interview.dto.*; import com.qingqiu.interview.dto.*;
import com.qingqiu.interview.entity.InterviewSession; import com.qingqiu.interview.entity.InterviewSession;
import com.qingqiu.interview.service.InterviewService; import com.qingqiu.interview.service.InterviewService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.util.UUID;
/** /**
* 面试流程相关接口 * 面试流程相关接口
*/ */
@Slf4j
@RestController @RestController
@RequestMapping("/api/v1/interview") @RequestMapping("/interview")
@RequiredArgsConstructor @RequiredArgsConstructor
public class InterviewController { public class InterviewController {
@@ -27,8 +31,11 @@ public class InterviewController {
public ApiResponse<InterviewResponse> startInterview( public ApiResponse<InterviewResponse> startInterview(
@RequestParam("resume") MultipartFile resume, @RequestParam("resume") MultipartFile resume,
@Validated @ModelAttribute InterviewStartRequest request) throws IOException { @Validated @ModelAttribute InterviewStartRequest request) throws IOException {
InterviewResponse response = interviewService.startInterview(resume, request); // InterviewResponse response = interviewService.startInterview(resume, request);
return ApiResponse.success(response); log.info("接收到的数据: {}", JSONObject.toJSONString(request));
InterviewResponse interviewResponse = new InterviewResponse();
interviewResponse.setSessionId(UUID.randomUUID().toString().replace("-", ""));
return ApiResponse.success(interviewResponse);
} }
/** /**

View File

@@ -19,7 +19,7 @@ import org.springframework.web.bind.annotation.RestController;
* @since 2025-08-30 * @since 2025-08-30
*/ */
@RestController @RestController
@RequestMapping("/api/v1/interview-question-progress") @RequestMapping("/interview-question-progress")
@RequiredArgsConstructor @RequiredArgsConstructor
public class InterviewQuestionProgressController { public class InterviewQuestionProgressController {

View File

@@ -9,6 +9,7 @@ import com.qingqiu.interview.entity.QuestionCategory;
import com.qingqiu.interview.service.IQuestionCategoryService; import com.qingqiu.interview.service.IQuestionCategoryService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -16,10 +17,11 @@ import java.util.List;
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/question-category") @RequestMapping("/question-category")
@RequiredArgsConstructor @RequiredArgsConstructor
public class QuestionCategoryController { public class QuestionCategoryController {
@Lazy
private final IQuestionCategoryService questionCategoryService; private final IQuestionCategoryService questionCategoryService;
/** /**
@@ -27,13 +29,14 @@ public class QuestionCategoryController {
*/ */
@GetMapping("/tree-list") @GetMapping("/tree-list")
public R<List<QuestionCategory>> getTreeList() { public R<List<QuestionCategory>> getTreeList() {
try {
List<QuestionCategory> list = questionCategoryService.getTreeList(); List<QuestionCategory> list = questionCategoryService.getTreeList();
return R.success(list); return R.success(list);
} catch (Exception e) {
log.error("获取分类树列表失败", e);
return R.error("获取分类树列表失败");
} }
@GetMapping("/question-tree-list")
public R<List<QuestionCategory>> getQuestionTreeList() {
// List<QuestionCategory> list = questionCategoryService.getQuestionTreeList();
return R.success();
} }
/** /**
@@ -98,10 +101,10 @@ public class QuestionCategoryController {
/** /**
* 更新分类 * 更新分类
*/ */
@PutMapping("/{id}") @PostMapping("/update")
public R<Void> update(@PathVariable Long id, @Validated @RequestBody QuestionCategoryDTO dto) { public R<Void> update(@RequestBody QuestionCategoryDTO dto) {
try { try {
questionCategoryService.updateCategory(id, dto); questionCategoryService.updateCategory(dto);
return R.success(); return R.success();
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.error("更新分类失败", e); log.error("更新分类失败", e);

View File

@@ -3,21 +3,24 @@ package com.qingqiu.interview.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.qingqiu.interview.common.res.R; import com.qingqiu.interview.common.res.R;
import com.qingqiu.interview.dto.ApiResponse; import com.qingqiu.interview.dto.ApiResponse;
import com.qingqiu.interview.dto.QuestionOptionsDTO;
import com.qingqiu.interview.dto.QuestionPageParams; import com.qingqiu.interview.dto.QuestionPageParams;
import com.qingqiu.interview.entity.Question; import com.qingqiu.interview.entity.Question;
import com.qingqiu.interview.service.QuestionService; import com.qingqiu.interview.service.QuestionService;
import com.qingqiu.interview.vo.QuestionAndCategoryTreeListVO;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.util.List;
/** /**
* 题库管理相关接口 * 题库管理相关接口
*/ */
@RestController @RestController
@RequestMapping("/api/v1/question") @RequestMapping("/question")
@RequiredArgsConstructor @RequiredArgsConstructor
public class QuestionController { public class QuestionController {
@@ -76,4 +79,9 @@ public class QuestionController {
questionService.useAiCheckQuestionData(); questionService.useAiCheckQuestionData();
return R.success(); return R.success();
} }
@PostMapping("/tree-list-category")
public R<List<QuestionAndCategoryTreeListVO>> getTreeListCategory(@RequestBody QuestionOptionsDTO dto) {
return R.success(questionService.getTreeListCategory(dto));
}
} }

View File

@@ -1,8 +1,11 @@
package com.qingqiu.interview.dto; package com.qingqiu.interview.dto;
import com.qingqiu.interview.vo.QuestionAndCategoryTreeListVO;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import lombok.Data; import lombok.Data;
import java.util.List;
@Data @Data
public class InterviewStartRequest { public class InterviewStartRequest {
@@ -10,6 +13,11 @@ public class InterviewStartRequest {
private String candidateName; private String candidateName;
private List<QuestionAndCategoryTreeListVO> selectedNodes;
@NotBlank(message = "面试类型不能为空")
private String model;
// 简历文件通过MultipartFile单独传递 // 简历文件通过MultipartFile单独传递
} }

View File

@@ -27,6 +27,10 @@ public class QuestionCategoryDTO {
@NotNull(message = "状态不能为空") @NotNull(message = "状态不能为空")
private Integer state; private Integer state;
private String ancestor;
private Integer level;
/** /**
* 父分类名称(用于前端显示) * 父分类名称(用于前端显示)
*/ */

View File

@@ -0,0 +1,17 @@
package com.qingqiu.interview.dto;
import lombok.Data;
import java.util.List;
@Data
public class QuestionOptionsDTO {
/** 分类id */
private List<Long> categoryIds;
/** 难度 */
private String difficulty;
}

View File

@@ -11,4 +11,6 @@ import lombok.experimental.Accessors;
public class QuestionPageParams extends PageBaseParams { public class QuestionPageParams extends PageBaseParams {
private String content; private String content;
private Long categoryId;
} }

View File

@@ -19,8 +19,11 @@ public class Question {
@TableField("content") @TableField("content")
private String content; private String content;
@TableField("category") @TableField("category_id")
private String category; private Long categoryId;
@TableField("category_name")
private String categoryName;
@TableField("difficulty") @TableField("difficulty")
private String difficulty; private String difficulty;

View File

@@ -1,15 +1,14 @@
package com.qingqiu.interview.entity; package com.qingqiu.interview.entity;
import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.annotation.*;
import java.time.LocalDateTime;
import java.io.Serializable;
import java.util.List;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors; import lombok.experimental.Accessors;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.List;
/** /**
* <p> * <p>
* 题型分类 * 题型分类
@@ -26,7 +25,7 @@ public class QuestionCategory implements Serializable {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO) @TableId(type = IdType.AUTO)
private Long id; private Long id;
/** /**

View File

@@ -2,6 +2,9 @@ package com.qingqiu.interview.mapper;
import com.qingqiu.interview.entity.QuestionCategory; import com.qingqiu.interview.entity.QuestionCategory;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** /**
* <p> * <p>
@@ -12,5 +15,5 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
* @since 2025-09-08 * @since 2025-09-08
*/ */
public interface QuestionCategoryMapper extends BaseMapper<QuestionCategory> { public interface QuestionCategoryMapper extends BaseMapper<QuestionCategory> {
List<QuestionCategory> batchFindByAncestorIdsUnion(@Param("searchIds") List<Long> searchIds);
} }

View File

@@ -1,6 +1,9 @@
package com.qingqiu.interview.mapper; package com.qingqiu.interview.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; 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 com.qingqiu.interview.entity.Question;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
@@ -18,6 +21,9 @@ public interface QuestionMapper extends BaseMapper<Question> {
Question selectByContent(@Param("content") String content); Question selectByContent(@Param("content") String content);
List<com.qingqiu.interview.dto.DashboardStatsResponse.CategoryStat> countByCategory(); List<DashboardStatsResponse.CategoryStat> countByCategory();
Page<Question> queryPage(@Param("page") Page<Question> page, @Param("params") QuestionPageParams params);
} }

View File

@@ -1,12 +1,10 @@
package com.qingqiu.interview.service; 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.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.qingqiu.interview.dto.QuestionCategoryDTO; import com.qingqiu.interview.dto.QuestionCategoryDTO;
import com.qingqiu.interview.dto.QuestionCategoryPageParams; import com.qingqiu.interview.dto.QuestionCategoryPageParams;
import com.qingqiu.interview.entity.QuestionCategory; import com.qingqiu.interview.entity.QuestionCategory;
import com.baomidou.mybatisplus.extension.service.IService;
import java.util.List; import java.util.List;
@@ -37,7 +35,7 @@ public interface IQuestionCategoryService extends IService<QuestionCategory> {
/** /**
* 更新分类 * 更新分类
*/ */
void updateCategory(Long id, QuestionCategoryDTO dto); void updateCategory(QuestionCategoryDTO dto);
/** /**
* 删除分类 * 删除分类
@@ -64,43 +62,14 @@ public interface IQuestionCategoryService extends IService<QuestionCategory> {
*/ */
List<QuestionCategory> searchByName(String name); List<QuestionCategory> searchByName(String name);
/**
* 获取某个分类的所有子孙分类
*/
List<QuestionCategory> getAllDescendants(Long parentId);
/**
* 批量更新分类状态(包含子孙分类)
*/
void batchUpdateState(Long parentId, Integer state);
/**
* 获取分类的完整路径名称
*/
String getFullPathName(Long categoryId);
/** /**
* 检查分类名称是否重复 * 检查分类名称是否重复
*/ */
boolean checkNameExists(String name, Long parentId, Long excludeId); boolean checkNameExists(String name, Long parentId, Long excludeId);
/** List<QuestionCategory> batchFindByAncestorIdsUnion(List<Long> searchIds);
* 移动分类(修改父分类)
*/
void moveCategory(Long id, Long newParentId);
/**
* 获取指定层级的分类
*/
List<QuestionCategory> getCategoriesByLevel(Integer level);
/**
* 获取启用的分类树
*/
List<QuestionCategory> getEnabledTreeList();
/**
* 根据父ID获取子分类
*/
List<QuestionCategory> getChildrenByParentId(Long parentId);
} }

View File

@@ -82,7 +82,7 @@ public class QuestionClassificationService {
for (JsonNode questionNode : questionsNode) { for (JsonNode questionNode : questionsNode) {
Question question = new Question() Question question = new Question()
.setContent(getTextValue(questionNode, "content")) .setContent(getTextValue(questionNode, "content"))
.setCategory(getTextValue(questionNode, "category")) .setCategoryName(getTextValue(questionNode, "category"))
.setDifficulty(getTextValue(questionNode, "difficulty")) .setDifficulty(getTextValue(questionNode, "difficulty"))
.setTags(getTextValue(questionNode, "tags")); .setTags(getTextValue(questionNode, "tags"));
@@ -112,7 +112,7 @@ public class QuestionClassificationService {
private boolean isValidQuestion(Question question) { private boolean isValidQuestion(Question question) {
return question.getContent() != null && !question.getContent().trim().isEmpty() return question.getContent() != null && !question.getContent().trim().isEmpty()
&& question.getCategory() != null && !question.getCategory().trim().isEmpty(); && question.getCategoryName() != null && !question.getCategoryName().trim().isEmpty();
} }
private List<Question> fallbackParsing(String content) { private List<Question> fallbackParsing(String content) {
@@ -126,7 +126,7 @@ public class QuestionClassificationService {
if (!line.isEmpty() && line.length() > 10) { // 过滤太短的内容 if (!line.isEmpty() && line.length() > 10) { // 过滤太短的内容
Question question = new Question() Question question = new Question()
.setContent(line) .setContent(line)
.setCategory("未分类") .setCategoryName("未分类")
.setDifficulty("Medium") .setDifficulty("Medium")
.setTags("待分类"); .setTags("待分类");
questions.add(question); questions.add(question);

View File

@@ -1,177 +1,30 @@
package com.qingqiu.interview.service; 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.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.dto.QuestionPageParams;
import com.qingqiu.interview.entity.Question; import com.qingqiu.interview.entity.Question;
import com.qingqiu.interview.mapper.QuestionMapper; import com.qingqiu.interview.entity.QuestionCategory;
import com.qingqiu.interview.service.llm.LlmService; import com.qingqiu.interview.vo.QuestionAndCategoryTreeListVO;
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 org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
@Slf4j public interface QuestionService extends IService<Question> {
@Service
@RequiredArgsConstructor
public class QuestionService {
private final QuestionMapper questionMapper; Page<Question> getQuestionPage(QuestionPageParams params);
private final QuestionClassificationService classificationService;
private final List<DocumentParser> documentParserList; // This will be injected by Spring
private final LlmService llmService;
/** void addQuestion(Question question);
* 分页查询题库
*/
public Page<Question> getQuestionPage(QuestionPageParams params) {
log.info("分页查询题库,当前页: {}, 每页数量: {}", params.getCurrent(), params.getSize());
return questionMapper.selectPage(
Page.of(params.getCurrent(), params.getSize()),
new LambdaQueryWrapper<Question>()
.like(StringUtils.isNotBlank(params.getContent()), Question::getContent, params.getContent())
.orderByDesc(Question::getCreatedTime)
);
}
/** void updateQuestion(Question question);
* 新增题目,并进行重复校验
*/
public void addQuestion(Question question) {
validateQuestion(question.getContent(), null);
log.info("新增题目: {}", question.getContent());
questionMapper.insert(question);
}
/** void deleteQuestion(Long id);
* 更新题目,并进行重复校验
*/
public void updateQuestion(Question question) {
validateQuestion(question.getContent(), question.getId());
log.info("更新题目ID: {}", question.getId());
questionMapper.updateById(question);
}
/** void importQuestionsFromFile(MultipartFile file) throws IOException;
* 删除题目
*/
public void deleteQuestion(Long id) {
log.info("删除题目ID: {}", id);
questionMapper.deleteById(id);
}
/** void useAiCheckQuestionData();
* 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));
String content = parser.parse(file.getInputStream()); List<QuestionAndCategoryTreeListVO> getTreeListCategory(QuestionOptionsDTO dto);
List<Question> 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<Question> questions = questionMapper.selectList(
new LambdaQueryWrapper<Question>()
.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<Long> list = questionsIds.toList(Long.class);
questionMapper.delete(
new LambdaQueryWrapper<Question>()
.notIn(Question::getId, list)
);
}
@NotNull
private static String getPrompt(List<Question> 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();
}
} }

View File

@@ -1,7 +1,6 @@
package com.qingqiu.interview.service.impl; package com.qingqiu.interview.service.impl;
import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.db.PageResult;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; 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.common.enums.CommonStateEnum;
import com.qingqiu.interview.dto.QuestionCategoryDTO; import com.qingqiu.interview.dto.QuestionCategoryDTO;
import com.qingqiu.interview.dto.QuestionCategoryPageParams; import com.qingqiu.interview.dto.QuestionCategoryPageParams;
import com.qingqiu.interview.entity.Question;
import com.qingqiu.interview.entity.QuestionCategory; import com.qingqiu.interview.entity.QuestionCategory;
import com.qingqiu.interview.mapper.QuestionCategoryMapper; import com.qingqiu.interview.mapper.QuestionCategoryMapper;
import com.qingqiu.interview.service.IQuestionCategoryService; import com.qingqiu.interview.service.IQuestionCategoryService;
import com.qingqiu.interview.service.QuestionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -32,7 +36,11 @@ import java.util.stream.Collectors;
*/ */
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor(onConstructor_ = {@Autowired, @Lazy})
public class QuestionCategoryServiceImpl extends ServiceImpl<QuestionCategoryMapper, QuestionCategory> implements IQuestionCategoryService { public class QuestionCategoryServiceImpl extends ServiceImpl<QuestionCategoryMapper, QuestionCategory> implements IQuestionCategoryService {
private final QuestionService questionService;
@Override @Override
public List<QuestionCategory> getTreeList() { public List<QuestionCategory> getTreeList() {
List<QuestionCategory> allCategories = getAllValidCategories(); List<QuestionCategory> allCategories = getAllValidCategories();
@@ -76,21 +84,21 @@ public class QuestionCategoryServiceImpl extends ServiceImpl<QuestionCategoryMap
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void updateCategory(Long id, QuestionCategoryDTO dto) { public void updateCategory(QuestionCategoryDTO dto) {
QuestionCategory category = getById(id); QuestionCategory category = getById(dto.getId());
if (category == null) { if (category == null) {
throw new RuntimeException("分类不存在"); throw new RuntimeException("分类不存在");
} }
// 检查名称是否重复(排除自身) // 检查名称是否重复(排除自身)
if (checkNameExists(dto.getName(), category.getParentId(), id)) { if (checkNameExists(dto.getName(), category.getParentId(), dto.getId())) {
throw new RuntimeException("同一层级下分类名称不能重复"); throw new RuntimeException("同一层级下分类名称不能重复");
} }
// 检查是否修改了父分类 // 检查是否修改了父分类
if (!category.getParentId().equals(dto.getParentId())) { // if (!category.getParentId().equals(dto.getParentId())) {
throw new RuntimeException("不支持直接修改父分类,请使用移动分类功能"); // throw new RuntimeException("不支持直接修改父分类,请使用移动分类功能");
} // }
BeanUtils.copyProperties(dto, category); BeanUtils.copyProperties(dto, category);
category.setUpdatedTime(LocalDateTime.now()); category.setUpdatedTime(LocalDateTime.now());
@@ -102,15 +110,21 @@ public class QuestionCategoryServiceImpl extends ServiceImpl<QuestionCategoryMap
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void deleteCategory(Long id) { public void deleteCategory(Long id) {
QuestionCategory category = getById(id); // 1. 查找所有需要删除的分类ID包括子分类
if (category == null) { List<Long> categoryIdsToDelete = getAllCategoryIdsToDelete(id);
throw new RuntimeException("分类不存在");
if (CollectionUtil.isEmpty(categoryIdsToDelete)) {
return;
} }
checkChildrenExists(id); // 2. 删除所有相关分类
removeById(id); this.removeByIds(categoryIdsToDelete);
log.info("删除分类成功:{}", id); // 3. 删除关联的题目数据
questionService.remove(
new LambdaQueryWrapper<Question>()
.in(Question::getCategoryId, categoryIdsToDelete)
);
} }
@Override @Override
@@ -173,42 +187,6 @@ public class QuestionCategoryServiceImpl extends ServiceImpl<QuestionCategoryMap
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@Override
public List<QuestionCategory> getAllDescendants(Long parentId) {
List<QuestionCategory> allCategories = getAllValidCategories();
return findDescendants(allCategories, parentId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void batchUpdateState(Long parentId, Integer state) {
List<QuestionCategory> 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<QuestionCategory> allCategories = getAllValidCategories();
Map<Long, QuestionCategory> categoryMap = allCategories.stream()
.collect(Collectors.toMap(QuestionCategory::getId, category -> category));
List<String> 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 @Override
public boolean checkNameExists(String name, Long parentId, Long excludeId) { public boolean checkNameExists(String name, Long parentId, Long excludeId) {
@@ -221,81 +199,22 @@ public class QuestionCategoryServiceImpl extends ServiceImpl<QuestionCategoryMap
} }
@Override @Override
@Transactional(rollbackFor = Exception.class) public List<QuestionCategory> batchFindByAncestorIdsUnion(List<Long> searchIds) {
public void moveCategory(Long id, Long newParentId) { return baseMapper.batchFindByAncestorIdsUnion(searchIds);
QuestionCategory category = getById(id);
if (category == null) {
throw new RuntimeException("分类不存在");
} }
if (category.getParentId().equals(newParentId)) {
throw new RuntimeException("新父分类与当前父分类相同");
}
validateParentCategory(newParentId);
// 检查名称是否重复
if (checkNameExists(category.getName(), newParentId, id)) {
throw new RuntimeException("目标父分类下已存在相同名称的分类");
}
// 更新父分类
category.setParentId(newParentId);
// 重新计算层级和路径
QuestionCategory newParent = getById(newParentId);
if (CommonConstant.ROOT_PARENT_ID.equals(newParentId)) {
category.setLevel(1);
category.setAncestor(String.valueOf(category.getId()));
} else {
category.setLevel(newParent.getLevel() + 1);
category.setAncestor(newParent.getAncestor() + "," + category.getId());
}
category.setUpdatedTime(LocalDateTime.now());
updateById(category);
log.info("移动分类成功id={}, newParentId={}", id, newParentId);
}
@Override
public List<QuestionCategory> getCategoriesByLevel(Integer level) {
List<QuestionCategory> allCategories = getAllValidCategories();
return allCategories.stream()
.filter(category -> level.equals(category.getLevel()))
.sorted(Comparator.comparingInt(QuestionCategory::getSort))
.collect(Collectors.toList());
}
@Override
public List<QuestionCategory> getEnabledTreeList() {
List<QuestionCategory> allCategories = getAllValidCategories();
List<QuestionCategory> enabledCategories = allCategories.stream()
.filter(category -> CommonStateEnum.ENABLED.getCode().equals(category.getState()))
.collect(Collectors.toList());
return buildCategoryTree(enabledCategories);
}
@Override
public List<QuestionCategory> getChildrenByParentId(Long parentId) {
List<QuestionCategory> allCategories = getAllValidCategories();
return allCategories.stream()
.filter(category -> parentId.equals(category.getParentId()))
.sorted(Comparator.comparingInt(QuestionCategory::getSort))
.collect(Collectors.toList());
}
// ============ 私有方法 ============ // ============ 私有方法 ============
private List<QuestionCategory> getAllValidCategories() { private List<QuestionCategory> getAllValidCategories() {
LambdaQueryWrapper<QuestionCategory> wrapper = new LambdaQueryWrapper<>(); // LambdaQueryWrapper<QuestionCategory> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(QuestionCategory::getDeleted, CommonConstant.ZERO) // wrapper.eq(QuestionCategory::getDeleted, CommonConstant.ZERO)
.orderByAsc(QuestionCategory::getSort); // .orderByAsc(QuestionCategory::getSort);
return list(wrapper); return list(
new LambdaQueryWrapper<QuestionCategory>()
.orderByDesc(QuestionCategory::getSort)
.orderByDesc(QuestionCategory::getCreatedTime)
);
} }
private List<QuestionCategory> buildCategoryTree(List<QuestionCategory> categories) { private List<QuestionCategory> buildCategoryTree(List<QuestionCategory> categories) {
@@ -361,16 +280,6 @@ public class QuestionCategoryServiceImpl extends ServiceImpl<QuestionCategoryMap
} }
} }
private void checkChildrenExists(Long parentId) {
List<QuestionCategory> allCategories = getAllValidCategories();
boolean hasChildren = allCategories.stream()
.anyMatch(category -> parentId.equals(category.getParentId()));
if (hasChildren) {
throw new RuntimeException("存在子分类,无法删除");
}
}
private List<QuestionCategory> findDescendants(List<QuestionCategory> allCategories, Long parentId) { private List<QuestionCategory> findDescendants(List<QuestionCategory> allCategories, Long parentId) {
List<QuestionCategory> descendants = new ArrayList<>(); List<QuestionCategory> descendants = new ArrayList<>();
@@ -384,4 +293,29 @@ public class QuestionCategoryServiceImpl extends ServiceImpl<QuestionCategoryMap
return descendants; return descendants;
} }
/**
* 递归获取所有需要删除的分类ID包括子分类
*
* @param parentId 父分类ID
* @return 所有需要删除的分类ID列表
*/
private List<Long> getAllCategoryIdsToDelete(Long parentId) {
List<Long> result = new ArrayList<>();
result.add(parentId);
// 查找直接子分类
List<QuestionCategory> children = this.list(
new LambdaQueryWrapper<QuestionCategory>()
.eq(QuestionCategory::getParentId, parentId)
);
// 递归查找所有子分类
for (QuestionCategory child : children) {
result.addAll(getAllCategoryIdsToDelete(child.getId()));
}
return result;
}
} }

View File

@@ -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<QuestionMapper, Question> implements QuestionService {
private final QuestionMapper questionMapper;
private final QuestionClassificationService classificationService;
private final List<DocumentParser> documentParserList; // This will be injected by Spring
private final LlmService llmService;
private final IQuestionCategoryService questionCategoryService;
/**
* 分页查询题库
*/
@Override
public Page<Question> 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<Question> 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<Question> questions = questionMapper.selectList(
new LambdaQueryWrapper<Question>()
.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<Long> list = questionsIds.toList(Long.class);
questionMapper.delete(
new LambdaQueryWrapper<Question>()
.notIn(Question::getId, list)
);
}
@NotNull
private static String getPrompt(List<Question> 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<QuestionAndCategoryTreeListVO> getTreeListCategory(QuestionOptionsDTO dto) {
if (StringUtils.isNoneBlank(dto.getDifficulty()) && dto.getDifficulty().equals("ALL")) {
dto.setDifficulty(null);
}
// 获取分类树列表
List<QuestionCategory> treeList = questionCategoryService.getTreeList();
List<QuestionCategory> 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<Question> questionList = list(
new LambdaQueryWrapper<Question>()
.in(CollectionUtil.isNotEmpty(dto.getCategoryIds()), Question::getCategoryId,
dto.getCategoryIds())
.eq(StringUtils.isNotBlank(dto.getDifficulty()), Question::getDifficulty, dto.getDifficulty())
.eq(Question::getDeleted, 0)
);
// 转换为VO对象并整合题目数据
List<QuestionAndCategoryTreeListVO> 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<QuestionAndCategoryTreeListVO> convertToVOListWithQuestions(
List<QuestionCategory> categoryList,
List<Question> questionList) {
if (CollectionUtil.isEmpty(categoryList)) {
return List.of();
}
// 按分类ID分组题目数据
Map<Long, List<Question>> 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<Long, List<Question>> 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<QuestionAndCategoryTreeListVO> 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<Question> 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<QuestionAndCategoryTreeListVO> 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;
}
}

View File

@@ -1,5 +1,7 @@
package com.qingqiu.interview.service.llm; package com.qingqiu.interview.service.llm;
import com.qingqiu.interview.ai.enums.LLMProvider;
public interface LlmService { public interface LlmService {
@@ -9,6 +11,7 @@ public interface LlmService {
* @return ai回复 * @return ai回复
*/ */
String chat(String prompt); String chat(String prompt);
String chat(String prompt, LLMProvider provider);
/** /**
* 与模型进行多轮对话 * 与模型进行多轮对话
@@ -18,6 +21,15 @@ public interface LlmService {
*/ */
String chat(String prompt, String token); 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); Integer getPromptTokens(String prompt);
} }

View File

@@ -7,8 +7,8 @@ import com.alibaba.dashscope.common.Role;
import com.alibaba.dashscope.tokenizers.Tokenizer; import com.alibaba.dashscope.tokenizers.Tokenizer;
import com.alibaba.dashscope.tokenizers.TokenizerFactory; import com.alibaba.dashscope.tokenizers.TokenizerFactory;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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.ai.factory.AIClientManager;
import com.qingqiu.interview.common.constants.AIStrategyConstant;
import com.qingqiu.interview.entity.AiSessionLog; import com.qingqiu.interview.entity.AiSessionLog;
import com.qingqiu.interview.mapper.AiSessionLogMapper; import com.qingqiu.interview.mapper.AiSessionLogMapper;
import com.qingqiu.interview.service.llm.LlmService; import com.qingqiu.interview.service.llm.LlmService;
@@ -42,7 +42,7 @@ public class QwenService implements LlmService {
public String chat(String prompt) { public String chat(String prompt) {
// log.info("开始调用API...."); // log.info("开始调用API....");
// long l = System.currentTimeMillis(); // long l = System.currentTimeMillis();
return aiClientManager.getClient(AIStrategyConstant.DEEPSEEK).chatCompletion(prompt); return chat(prompt, LLMProvider.DEEPSEEK);
// GenerationParam param = GenerationParam.builder() // GenerationParam param = GenerationParam.builder()
// .model(DEEPSEEK_3) // 可根据需要更换模型 // .model(DEEPSEEK_3) // 可根据需要更换模型
// .messages(Collections.singletonList(createMessage(Role.USER.getValue(), prompt))) // .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 @Override
public String chat(String prompt, String token) { 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查询会话记录 // 根据token查询会话记录
List<AiSessionLog> aiSessionLogs = aiSessionLogMapper.selectList( List<AiSessionLog> aiSessionLogs = aiSessionLogMapper.selectList(
new LambdaQueryWrapper<AiSessionLog>() new LambdaQueryWrapper<AiSessionLog>()
@@ -113,7 +157,7 @@ public class QwenService implements LlmService {
createMessage(Role.USER.getValue(), prompt) createMessage(Role.USER.getValue(), prompt)
); );
String aiResponse = aiClientManager.getClient(AIStrategyConstant.DEEPSEEK).chatCompletion(messages); String aiResponse = aiClientManager.getClient(provider).chatCompletion(messages);
// 存储用户提问 // 存储用户提问
AiSessionLog userLog = new AiSessionLog(); AiSessionLog userLog = new AiSessionLog();
userLog.setToken(token); userLog.setToken(token);
@@ -128,40 +172,6 @@ public class QwenService implements LlmService {
aiLog.setContent(aiResponse); aiLog.setContent(aiResponse);
aiSessionLogMapper.insert(aiLog); aiSessionLogMapper.insert(aiLog);
return aiResponse; 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);
// }
} }
/** /**

View File

@@ -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<QuestionAndCategoryTreeListVO> children;
private Integer count;
}

View File

@@ -2,4 +2,16 @@
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.qingqiu.interview.mapper.QuestionCategoryMapper"> <mapper namespace="com.qingqiu.interview.mapper.QuestionCategoryMapper">
<select id="batchFindByAncestorIdsUnion" resultType="com.qingqiu.interview.entity.QuestionCategory">
SELECT DISTINCT qc.*
FROM question_category qc
INNER JOIN (
<foreach collection="searchIds" item="searchId" separator="UNION ALL">
SELECT #{searchId} as search_id
</foreach>
) AS search_values
ON FIND_IN_SET(search_values.search_id, qc.ancestor) > 0
OR qc.id = search_values.search_id
WHERE qc.deleted = 0
</select>
</mapper> </mapper>

View File

@@ -5,14 +5,14 @@
<select id="selectByCategory" resultType="com.qingqiu.interview.entity.Question"> <select id="selectByCategory" resultType="com.qingqiu.interview.entity.Question">
SELECT * SELECT *
FROM question FROM question
WHERE category = #{category} AND deleted = 0 WHERE category_name = #{category} AND deleted = 0
ORDER BY created_time DESC ORDER BY created_time DESC
</select> </select>
<select id="selectByCategories" resultType="com.qingqiu.interview.entity.Question"> <select id="selectByCategories" resultType="com.qingqiu.interview.entity.Question">
SELECT * SELECT *
FROM question FROM question
WHERE category IN WHERE category_name IN
<foreach collection="categories" item="category" open="(" separator="," close=")"> <foreach collection="categories" item="category" open="(" separator="," close=")">
#{category} #{category}
</foreach> </foreach>
@@ -23,7 +23,7 @@
<select id="selectRandomByCategories" resultType="com.qingqiu.interview.entity.Question"> <select id="selectRandomByCategories" resultType="com.qingqiu.interview.entity.Question">
SELECT * SELECT *
FROM question FROM question
WHERE category IN WHERE category_name IN
<foreach collection="categories" item="category" open="(" separator="," close=")"> <foreach collection="categories" item="category" open="(" separator="," close=")">
#{category} #{category}
</foreach> </foreach>
@@ -40,11 +40,34 @@
</select> </select>
<select id="countByCategory" resultType="com.qingqiu.interview.dto.DashboardStatsResponse$CategoryStat"> <select id="countByCategory" resultType="com.qingqiu.interview.dto.DashboardStatsResponse$CategoryStat">
SELECT category as name, COUNT(*) as value SELECT category_name as name, COUNT(*) as value
FROM question FROM question
WHERE deleted = 0 WHERE deleted = 0
GROUP BY category GROUP BY category_name
ORDER BY value DESC ORDER BY value DESC
</select> </select>
<select id="queryPage" resultType="com.qingqiu.interview.entity.Question">
select q.id,
q.content,
q.category_id,
q.category_name,
q.difficulty,
q.tags,
q.created_time,
q.updated_time,
q.deleted
from question q
inner join question_category qc on q.category_id = qc.id
where q.deleted = 0
<if test="params.content != null">
and q.content like concat('%', #{params.content}, '%')
</if>
<if test="params.categoryId != null">
and (find_in_set(#{params.categoryId}, qc.ancestor)
or q.category_id = #{params.categoryId})
</if>
order by q.created_time desc
</select>
</mapper> </mapper>