11 Commits

Author SHA1 Message Date
80dcb23bbc 使用spring-ai-alibaba重构项目 2025-11-24 13:37:37 +08:00
5fb4ed754c 使用spring-ai-alibaba重构项目 2025-10-22 19:53:26 +08:00
fac1346104 使用spring-ai-alibaba重构项目 2025-10-10 18:03:16 +08:00
5711d611f2 修改打包配置 2025-09-26 12:42:04 +08:00
d3b5ca0033 优化代码 2025-09-21 21:19:44 +08:00
df5aa0b9c6 优化代码 2025-09-20 22:24:07 +08:00
08b4b8b206 优化代码 2025-09-20 21:43:36 +08:00
8b357fbb93 优化代码 2025-09-19 15:19:41 +08:00
a384bbfd16 修改AI面试相关内容 2025-09-17 21:36:09 +08:00
7f24d65d76 添加分类的controller 2025-09-14 22:21:03 +08:00
d14b46d007 修改代码 2025-09-11 22:33:53 +08:00
132 changed files with 4513 additions and 1865 deletions

0
.gitignore vendored Normal file → Executable file
View File

0
HELP.md Normal file → Executable file
View File

0
LICENSE Normal file → Executable file
View File

0
README.md Normal file → Executable file
View File

View File

@@ -1,135 +0,0 @@
<template>
<div class="dashboard-container">
<!-- 欢迎横幅 -->
<el-card shadow="never" class="welcome-banner">
<div class="welcome-content">
<div class="welcome-text">
<h2>欢迎回来</h2>
<p>准备好开始您的下一次模拟面试了吗在这里管理您的题库不断提升面试技巧</p>
</div>
<img src="/src/assets/dashboard-hero.svg" alt="仪表盘插图" class="welcome-illustration" />
</div>
</el-card>
<!-- 功能导航 -->
<div class="feature-grid">
<router-link to="/interview" class="feature-card-link">
<el-card shadow="hover" class="feature-card">
<div class="card-content">
<el-icon class="card-icon" style="background-color: #ecf5ff; color: #409eff;"><ChatLineRound /></el-icon>
<div class="text-content">
<h3>开始模拟面试</h3>
<p>上传简历与AI进行实战演练</p>
</div>
</div>
</el-card>
</router-link>
<router-link to="/question-bank" class="feature-card-link">
<el-card shadow="hover" class="feature-card">
<div class="card-content">
<el-icon class="card-icon" style="background-color: #f0f9eb; color: #67c23a;"><MessageBox /></el-icon>
<div class="text-content">
<h3>题库管理</h3>
<p>新增编辑和导入您的面试题库</p>
</div>
</div>
</el-card>
</router-link>
<router-link to="/history" class="feature-card-link">
<el-card shadow="hover" class="feature-card">
<div class="card-content">
<el-icon class="card-icon" style="background-color: #fdf6ec; color: #e6a23c;"><Finished /></el-icon>
<div class="text-content">
<h3>面试历史</h3>
<p>查看过往的面试记录与AI复盘报告</p>
</div>
</div>
</el-card>
</router-link>
</div>
</div>
</template>
<script setup>
// 导入Element Plus图标
import { ChatLineRound, MessageBox, Finished } from '@element-plus/icons-vue';
</script>
<style scoped>
/* 仪表盘容器 */
.dashboard-container {
padding: 10px;
}
/* 欢迎横幅 */
.welcome-banner {
border: none;
margin-bottom: 20px;
}
.welcome-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.welcome-text h2 {
font-size: 1.8em;
margin-top: 0;
color: #303133;
}
.welcome-text p {
color: #606266;
font-size: 1.1em;
}
.welcome-illustration {
width: 200px;
height: auto;
}
/* 功能网格布局 */
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.feature-card-link {
text-decoration: none;
}
.feature-card .card-content {
display: flex;
align-items: center;
padding: 20px;
transition: transform 0.3s, box-shadow 0.3s;
}
.feature-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
.card-icon {
font-size: 32px;
padding: 15px;
border-radius: 50%;
margin-right: 20px;
}
.text-content h3 {
margin: 0 0 8px 0;
color: #303133;
font-size: 1.1em;
}
.text-content p {
margin: 0;
color: #909399;
font-size: 0.9em;
}
</style>

0
mvnw vendored Normal file → Executable file
View File

0
mvnw.cmd vendored Normal file → Executable file
View File

139
pom.xml Normal file → Executable file
View File

@@ -5,7 +5,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.10-SNAPSHOT</version>
<version>3.4.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.qingqiu</groupId>
@@ -13,6 +13,7 @@
<version>0.0.1-SNAPSHOT</version>
<name>AI-Interview</name>
<description>AI-Interview</description>
<!-- TODO: 考虑删除空的元数据元素 -->
<url/>
<licenses>
<license/>
@@ -28,6 +29,9 @@
</scm>
<properties>
<java.version>17</java.version>
<spring-ai.version>1.0.0</spring-ai.version>
<spring-ai-alibaba.version>1.0.0.2</spring-ai-alibaba.version>
<spring-boot.version>3.4.5</spring-boot.version>
</properties>
<dependencies>
<!-- MyBatis-Plus -->
@@ -43,31 +47,68 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- aop和aspect -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId> <!-- 用于 WebClient -->
</dependency>
<!-- &lt;!&ndash; Spring AI Dependencies &ndash;&gt;-->
<!-- TODO: 考虑删除已注释的依赖 -->
<!-- <dependency>-->
<!-- <groupId>org.springframework.ai</groupId>-->
<!-- <artifactId>spring-ai-openai-spring-boot-starter</artifactId>-->
<!-- <exclusions>-->
<!-- <exclusion>-->
<!-- <groupId>com.alibaba.cloud.ai</groupId>-->
<!-- <artifactId>spring-ai-alibaba-autoconfigure</artifactId>-->
<!-- </exclusion>-->
<!-- <exclusion>-->
<!-- <groupId>org.springframework.ai</groupId>-->
<!-- <artifactId>spring-ai-core</artifactId>-->
<!-- </exclusion>-->
<!-- </exclusions>-->
<!-- <version>1.0.0-SNAPSHOT</version>-->
<!-- </dependency>-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>dashscope-sdk-java</artifactId>
<version>2.21.5</version>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
</dependency>
<!-- TODO: 检查内存相关依赖是否都需要 -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-memory</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-memory-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-memory-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>5.2.0</version>
</dependency>
<!-- 集成redis依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- TODO: 考虑删除已注释的依赖 -->
<!-- <dependency>-->
<!-- <groupId>com.alibaba</groupId>-->
<!-- <artifactId>dashscope-sdk-java</artifactId>-->
<!-- <version>2.21.5</version>-->
<!-- </dependency>-->
<!-- https://mvnrepository.com/artifact/cn.hutool/hutool-all -->
<dependency>
<groupId>cn.hutool</groupId>
@@ -124,16 +165,28 @@
</dependency>
</dependencies>
<dependencyManagement>
<!-- <dependencies>-->
<!-- <dependency>-->
<!-- <groupId>org.springframework.ai</groupId>-->
<!-- <artifactId>spring-ai-bom</artifactId>-->
<!-- <version>1.0.0-M5</version> &lt;!&ndash; 或最新版本 &ndash;&gt;-->
<!-- <type>pom</type>-->
<!-- <scope>import</scope>-->
<!-- </dependency>-->
<!-- </dependencies>-->
<dependencies>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-bom</artifactId>
<version>${spring-ai-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-bom</artifactId>
@@ -142,10 +195,19 @@
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<finalName>ai-interview</finalName>
<plugins>
<!-- TODO: 检查Spring Boot插件版本是否与父POM一致 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>3.5.0</version>
</plugin>
<!-- TODO: 考虑在正式环境中启用测试 -->
<!-- maven 打包时跳过测试 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
@@ -156,33 +218,6 @@
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</repository>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</pluginRepository>
</pluginRepositories>
</project>

0
sql/.idea/.gitignore generated vendored Normal file → Executable file
View File

View File

View File

@@ -1,22 +0,0 @@
package com.qingqiu.interview.ai.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.io.Serial;
import java.io.Serializable;
@Data
@Accessors(chain = true)
@AllArgsConstructor
@NoArgsConstructor
public class Message implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String role;
private String content;
}

View File

@@ -1,7 +0,0 @@
package com.qingqiu.interview.ai.factory;
import com.qingqiu.interview.ai.service.AIClientService;
public interface AIClientFactory {
AIClientService createAIClient();
}

View File

@@ -1,25 +0,0 @@
package com.qingqiu.interview.ai.factory;
import com.qingqiu.interview.ai.service.AIClientService;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
public class AIClientManager {
private final Map<String, AIClientFactory> factories;
public AIClientManager(Map<String, AIClientFactory> factories) {
this.factories = factories;
}
public AIClientService getClient(String aiType) {
String factoryName = aiType + "ClientFactory";
AIClientFactory factory = factories.get(factoryName);
if (factory == null) {
throw new IllegalArgumentException("不支持的AI type: " + aiType);
}
return factory.createAIClient();
}
}

View File

@@ -1,14 +0,0 @@
package com.qingqiu.interview.ai.factory;
import com.qingqiu.interview.ai.service.AIClientService;
import com.qingqiu.interview.ai.service.impl.DeepSeekClientServiceImpl;
import com.qingqiu.interview.common.utils.SpringApplicationContextUtil;
import org.springframework.stereotype.Service;
@Service
public class DeepSeekClientFactory implements AIClientFactory{
@Override
public AIClientService createAIClient() {
return SpringApplicationContextUtil.getBean(DeepSeekClientServiceImpl.class);
}
}

View File

@@ -1,14 +0,0 @@
package com.qingqiu.interview.ai.factory;
import com.qingqiu.interview.ai.service.AIClientService;
import com.qingqiu.interview.ai.service.impl.QwenClientServiceImpl;
import com.qingqiu.interview.common.utils.SpringApplicationContextUtil;
import org.springframework.stereotype.Service;
@Service
public class QwenClientFactory implements AIClientFactory{
@Override
public AIClientService createAIClient() {
return SpringApplicationContextUtil.getBean(QwenClientServiceImpl.class);
}
}

View File

@@ -1,13 +0,0 @@
package com.qingqiu.interview.ai.service;
import com.alibaba.dashscope.common.Message;
import java.util.List;
public abstract class AIClientService {
public abstract String chatCompletion(String prompt);
public String chatCompletion(List<Message> messages) {
return null;
}
}

View File

@@ -1,65 +0,0 @@
package com.qingqiu.interview.ai.service.impl;
import com.alibaba.dashscope.common.Message;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.qingqiu.interview.ai.service.AIClientService;
import com.qingqiu.interview.common.service.HttpService;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static com.qingqiu.interview.common.utils.AIUtils.createUserMessage;
/**
* deepseek 接入
*/
@Service
@RequiredArgsConstructor
public class DeepSeekClientServiceImpl extends AIClientService {
private final HttpService httpService;
@Value("${deepseek.api-url}")
private String apiUrl;
@Value("${deepseek.api-key}")
private String apiKey;
@Override
public String chatCompletion(String prompt) {
return chatCompletion(Collections.singletonList(createUserMessage(prompt)));
}
@Override
public String chatCompletion(List<Message> messages) {
JSONObject jsonObject = new JSONObject();
jsonObject.put("type", "json_object");
Map<String, Object> requestBody = Map.of(
"model", "deepseek-chat",
"messages", messages,
"max_tokens", 8192,
"response_format", Map.of("type", "json_object")
);
String res = httpService.postWithAuth(
apiUrl,
requestBody,
String.class,
"Bearer " + apiKey
).block();
if (StringUtils.isNotBlank(res)) {
JSONObject jsonRes = JSONObject.parse(res);
JSONArray choices = jsonRes.getJSONArray("choices");
JSONObject resContent = choices.getJSONObject(0);
JSONObject message = resContent.getJSONObject("message");
return message.getString("content");
}
return null;
}
}

View File

@@ -1,60 +0,0 @@
package com.qingqiu.interview.ai.service.impl;
import com.alibaba.dashscope.aigc.generation.Generation;
import com.alibaba.dashscope.aigc.generation.GenerationParam;
import com.alibaba.dashscope.aigc.generation.GenerationResult;
import com.alibaba.dashscope.common.Message;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.InputRequiredException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.qingqiu.interview.ai.service.AIClientService;
import com.qingqiu.interview.common.res.ResultCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import static com.qingqiu.interview.common.constants.QwenModelConstant.QWEN_PLUS_LATEST;
import static com.qingqiu.interview.common.utils.AIUtils.createUserMessage;
@Slf4j
@Service
@RequiredArgsConstructor
public class QwenClientServiceImpl extends AIClientService {
@Value("${dashscope.api-key}")
private String apiKey;
private final Generation generation;
@Override
public String chatCompletion(String prompt) {
return chatCompletion(Collections.singletonList(createUserMessage(prompt)));
}
@Override
public String chatCompletion(List<Message> messages) {
GenerationParam param = GenerationParam.builder()
.model(QWEN_PLUS_LATEST) // 可根据需要更换模型
.messages(messages)
.resultFormat(GenerationParam.ResultFormat.MESSAGE)
.apiKey(apiKey)
.build();
GenerationResult result = null;
try {
result = generation.call(param);
return result.getOutput().getChoices().get(0).getMessage().getContent();
} catch (NoApiKeyException e) {
log.error("没有api key请先确认配置");
throw new com.qingqiu.interview.common.ex.ApiException(ResultCode.INTERNAL);
} catch (ApiException | InputRequiredException e) {
log.error("调用AI服务失败", e);
throw new com.qingqiu.interview.common.ex.ApiException(ResultCode.INTERNAL);
}
}
}

View File

@@ -0,0 +1,44 @@
package com.qingqiu.interview.common.constants;
/**
* 聊天相关常量类
* 定义了聊天功能中使用的各种常量值
*/
public class ChatConstant {
/**
* MySQL聊天记录存储的Bean名称
*/
public static final String MYSQL_CHAT_MEMORY_BEAN_NAME = "mysql-chat-memory";
/**
* Redis聊天记录存储的Bean名称
*/
public static final String REDIS_CHAT_MEMORY_BEAN_NAME = "redis-chat-memory";
/**
* 聊天记录最大保存消息数
*/
public static final Integer MAX_MESSAGES = 100;
/**
* DashScope聊天模型的Bean名称
*/
public static final String DASH_SCOPE_CHAT_MODEL_BEAN_NAME = "dash-scope-chat-model";
public static final String OPEN_AI_CHAT_MODEL_BEAN_NAME = "open-ai-chat-model";
/**
* DashScope聊天客户端的Bean名称
*/
public static final String DASH_SCOPE_CHAT_CLIENT_BEAN_NAME = "dash-scope-chat-client";
/**
* OpenAI聊天客户端的Bean名称
*/
public static final String OPEN_AI_CHAT_CLIENT_BEAN_NAME = "open-ai-chat-client";
/**
* 最大补全token数量
*/
public static final Integer MAX_COMPLETION_TOKENS = 8192;
}

View File

@@ -0,0 +1,17 @@
package com.qingqiu.interview.common.constants;
import java.math.BigDecimal;
/**
* <h1>公共常量</h1>
* @author huangpeng
* @date 2025/9/11 09:30
*/
public class CommonConstant {
public static final Integer ZERO = 0;
public static final Integer ONE = 1;
public static final Long ROOT_PARENT_ID = 0L;
public static final Integer MAX_TOKEN = 64000;
public static final BigDecimal DEFAULT_TRUNCATE_RATIO = new BigDecimal("0.1");
}

View File

@@ -1,4 +1,4 @@
package com.qingqiu.interview.dto;
package com.qingqiu.interview.common.dto;
import lombok.Data;
import lombok.EqualsAndHashCode;

View File

@@ -0,0 +1,63 @@
package com.qingqiu.interview.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* <h1></h1>
*
* @author huangpeng
* @date 2025/9/11 09:49
*/
@Getter
@AllArgsConstructor
public enum CommonStateEnum {
/**
* 禁用状态
*/
DISABLED(0, "禁用"),
/**
* 启用状态
*/
ENABLED(1, "启用"),
;
/**
* 状态码
*/
private final Integer code;
private final String value;
/**
* 根据状态码获取枚举
*/
public static CommonStateEnum getByCode(Integer code) {
if (code == null) {
return null;
}
for (CommonStateEnum state : values()) {
if (state.getCode().equals(code)) {
return state;
}
}
return null;
}
/**
* 根据标识获取枚举
*/
public static CommonStateEnum getByValue(String value) {
if (value == null || value.isEmpty()) {
return null;
}
for (CommonStateEnum state : values()) {
if (state.getValue().equalsIgnoreCase(value)) {
return state;
}
}
return null;
}
}

View File

@@ -0,0 +1,33 @@
package com.qingqiu.interview.common.enums;
import lombok.Getter;
/**
* <h1></h1>
*
* @author qingqiu
* @date 2025/9/18 16:43
*/
@Getter
public enum DocumentParserProvider {
PDF("pdf"),
MARKDOWN("md"),
;
private final String code;
DocumentParserProvider(String code) {
this.code = code;
}
public static DocumentParserProvider fromCode(String code) {
for (DocumentParserProvider provider : values()) {
if (provider.getCode().equals(code)) {
return provider;
}
}
throw new IllegalArgumentException("Unknown provider: " + code);
}
}

View File

@@ -0,0 +1,30 @@
package com.qingqiu.interview.common.enums;
import lombok.Getter;
@Getter
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 static LLMProvider fromCode(String code) {
for (LLMProvider provider : values()) {
if (provider.getCode().equals(code)) {
return provider;
}
}
throw new IllegalArgumentException("Unknown provider: " + code);
}
}

View File

View File

View File

0
src/main/java/com/qingqiu/interview/common/res/R.java Normal file → Executable file
View File

View File

View File

View File

@@ -1,26 +0,0 @@
package com.qingqiu.interview.common.utils;
import com.alibaba.dashscope.common.Message;
import com.alibaba.dashscope.common.Role;
public class AIUtils {
public static Message createMessage(String role, String content) {
return Message.builder()
.role(role)
.content(content)
.build();
}
public static Message createUserMessage(String prompt) {
return createMessage(Role.USER.getValue(), prompt);
}
public static Message createAIMessage(String prompt) {
return createMessage(Role.ASSISTANT.getValue(), prompt);
}
public static Message createSystemMessage(String prompt) {
return createMessage(Role.SYSTEM.getValue(), prompt);
}
}

View File

@@ -0,0 +1,430 @@
package com.qingqiu.interview.common.utils;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.chat.prompt.SystemPromptTemplate;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 提示模板工具类
*/
public class PromptTemplateUtils {
/**
* 获取提取技能的提示
*
* @param resumeContent 简历内容
* @return 提示
*/
public static Prompt getExtractSkillsPrompt(String resumeContent) {
SystemMessage systemMessage = new SystemMessage("""
你是一位资深的技术面试官,以严格和深入著称。
你需要从提供的简历内容中,提取出所有与职位相关的技能。
请按照以下JSON格式返回
{"skills": ["技能1", "技能2", "..."]}
""");
UserMessage userMessage = new UserMessage(resumeContent);
return new Prompt(List.of(userMessage, systemMessage));
}
/**
* 获取AI面试官的提示
*
* @param params 参数
* @return 提示
*/
public static Prompt getAiInterviewerPrompt(Map<String, Object> params) {
SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate("""
你是一位经验丰富的软件开发技术面试官,具备以下特质:
## 专业能力
- 拥有10年以上软件开发和团队管理经验
- 熟悉各种主流技术栈和架构模式
- 擅长通过技术面试评估候选人的真实能力
- 能够设计多层次、递进式的面试问题
## 面试原则
1. **精准匹配**: 问题必须紧密结合岗位要求和候选人背景
2. **层次递进**: 从基础概念到深度应用,从理论到实践
3. **场景化考察**: 基于真实项目场景设计问题
4. **全面评估**: 涵盖技术深度、问题解决能力、系统思维
## 本岗位招聘要求
[{jobRequirements}]
## 输出要求
- 严格按照指定的JSON格式输出
- 不得包含任何JSON格式之外的内容
- 不得添加代码块标记(```json)或其他解释性文字
- 确保生成的问题数量与要求完全一致
""");
PromptTemplate userPromptTemplate = new PromptTemplate("""
请根据岗位招聘要求设计 {count} 道技术面试题。
## 候选人信息
### 技术栈
{skills}
### 简历内容
{resume}
## 面试题设计要求
### 覆盖维度
1. **基础理论** (20%): 核心概念、原理机制
2. **项目实践** (40%): 具体项目中的技术难点和解决方案
3. **系统设计** (20%): 架构思维、技术选型、性能优化
4. **问题解决** (20%): 调试能力、故障排查、代码优化
### 难度分布
- 基础题 (30%): 验证核心技能掌握情况
- 进阶题 (50%): 考察深度理解和实际应用
- 高阶题 (20%): 评估架构能力和创新思维
### 问题类型
- **概念阐述**: "请解释...""什么是..."
- **场景分析**: "在你的XX项目中...""如果遇到XX问题..."
- **方案设计**: "如何设计...""请给出..."
- **比较选择**: "XX和YY的区别...""为什么选择..."
## 输出格式
必须严格按照以下JSON格式输出不得有任何偏差
{jsonRes}
请立即开始生成面试题:
""");
String s = """
{
"questions": [
{
"id": "ai-gen-1",
"content": "问题内容..."
}
]
}
""";
params.put("jsonRes", s);
return new Prompt(List.of(userPromptTemplate.createMessage(params), systemPromptTemplate.createMessage(params)));
}
public static Prompt getLocalInterviewPrompt(Map<String, Object> params) {
SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate("""
你是一位资深的技术面试专家。
## 你的专业背景
- 10年+ 软件开发和技术管理经验
- 熟悉前端、后端、全栈、嵌入式、移动端、DevOps、大数据、AI等各技术领域
- 精通各种技术栈的深度和广度评估
- 擅长根据岗位特点和候选人背景进行精准匹配
## 通用岗位分类及评估重点
### 后端开发岗位
- 重点框架熟练度、数据库设计、API设计、性能优化、并发处理
- 核心技能:编程语言基础、框架应用、系统设计、问题排查
### 前端开发岗位
- 重点UI/UX实现、性能优化、跨浏览器兼容、现代框架使用
- 核心技能HTML/CSS/JS、框架应用、工程化、用户体验
### 全栈开发岗位
- 重点前后端技术栈、系统架构、DevOps流程
- 核心技能:多技术栈掌握、系统整合、项目管理
### 架构师岗位
- 重点:系统设计、技术选型、性能调优、团队技术规划
- 核心技能:架构设计、技术决策、团队领导、业务理解
## 筛选策略框架
### 匹配维度权重
1. **技术栈匹配度** (35%): 候选人技能与岗位要求的重合度
2. **项目经验相关性** (30%): 过往项目与岗位场景的匹配度
3. **技能深度评估** (20%): 根据经验年限判断技能掌握深度
4. **发展潜力考量** (15%): 学习能力和技术视野的考察
## 本岗位招聘要求
[{jobRequirements}]
## 输出规范
- 必须输出标准JSON格式{jsonRes}
- 不得包含任何解释、注释或代码块标记
- 确保所选题目ID真实存在于题库中
- 保证题目数量与要求完全一致
""");
PromptTemplate userPromptTemplate = new PromptTemplate("""
请根据岗位招聘要求,从题库中筛选筛选 {count} 道技术面试题。
## 候选人档案
### 技术栈
{skills}
### 简历内容
{resume}
## 筛选要求
请根据以上信息,结合你的专业框架,从题库中筛选出最能评估该候选人是否适合此岗位的面试题。
### 重点考虑因素
1. 岗位核心技术要求与候选人技能的匹配度
2. 候选人项目经验与岗位应用场景的关联性
3. 题目难度与候选人经验水平的适配性
4. 题目类型的多样性(理论+实践+设计)
"""
);
params.put("jsonRes",
"""
{
"questions": [
{
"id": "1",
"content": "问题内容..."
},
{
"id": "3",
"content": "问题内容..."
}
...
]
}
"""
);
return new Prompt(List.of(
userPromptTemplate.createMessage(params),
systemPromptTemplate.createMessage(params)
));
}
public static Prompt getEvaluatePrompt(Map<String, Object> params) {
// SystemMessage systemMessage = new SystemMessage("""
// 你是一位经验丰富的高级技术面试官,以公正、严谨、深入的评估风格著称。
// ## 你的专业特质
// - 拥有15年+ 技术开发和面试经验
// - 善于通过追问挖掘候选人的真实技术水平
// - 能够准确识别标准答案、实际经验和深度理解的区别
// - 注重考察解决问题的思路和实际应用能力
//
// """);
PromptTemplate promptTemplate = new PromptTemplate("""
请对候选人的回答进行专业评估。
## 评估维度及权重
### 技术准确性 (30%)
- 概念理解的正确性
- 技术细节的准确程度
- 是否存在明显错误或误解
### 深度与广度 (25%)
- 知识的深入程度
- 相关技术的关联理解
- 是否能举一反三
### 实践经验 (25%)
- 是否有真实项目经验支撑
- 能否结合具体场景说明
- 对技术选型和权衡的理解
### 表达能力 (20%)
- 逻辑清晰度
- 表达的完整性
- 专业术语使用的准确性
## 评分标准
### 优秀 (85-100分)
- 回答准确、深入、有见解
- 能结合实际项目经验
- 表达清晰、逻辑严密
- 展现出深度思考和实践能力
### 良好 (70-84分)
- 回答基本正确,有一定深度
- 有实际应用经验
- 表达较为清晰
- 个别地方可能需要补充
### 及格 (60-69分)
- 回答基本正确但较浅显
- 缺乏深入理解或实践经验
- 表达尚可但不够完整
- 需要进一步考察
### 不及格 (0-59分)
- 回答错误或严重不完整
- 基础概念理解有误
- 表达混乱或逻辑不清
- 明显缺乏相关经验
## 追问策略
### 何时追问
1. **概念模糊**: 候选人给出的概念定义不够准确或完整
2. **缺少细节**: 回答过于宽泛,缺乏具体的技术细节
3. **经验质疑**: 怀疑候选人是否有真实的项目经验
4. **深度探索**: 基础回答正确,想考察更深层次的理解
### 追问类型
1. **澄清追问**: "你刚才提到XX能具体解释一下吗"
2. **场景追问**: "在实际项目中你是如何处理XX问题的"
3. **对比追问**: "XX和YY有什么区别你会如何选择"
4. **深度追问**: "如果遇到XX情况你会如何优化"
### 追问限制
- 单个问题最多追问3次
- 追问应该递进深入,不重复
- 追问后必须给出综合评估
- 避免过度纠缠细节,影响整体面试节奏
## 当前问题
{question}
## 候选人回答
{candidateAnswer}
## 评估任务
### 请分析以下方面
1. **技术准确性**: 回答是否正确,有无技术错误
2. **完整性**: 是否涵盖了问题的关键要点
3. **深度**: 是否展现了深入的理解和思考
4. **实践性**: 是否结合了实际项目经验
5. **表达质量**: 逻辑是否清晰,表达是否准确
### 追问决策逻辑
- 如果回答存在明显不足,制定一个精准的追问来深入考察
- 如果已经追问过2次本次必须结束追问continueAsking: false
- 追问应该针对性强,避免过于宽泛
- 优先考察核心技术能力,避免偏离主题
### AI标准答案要求
请提供一个简洁、准确的技术答案作为参考,突出关键要点。
## 输出格式要求
严格按照以下JSON格式输出确保字段完整且格式正确
{jsonRes}
开始评估:
""");
params.put(
"jsonRes",
"""
{
"feedback": "对回答的具体评价,指出优点和不足",
"suggestions": "具体的改进建议和学习方向",
"aiAnswer": "简洁准确的标准答案要点",
"score": 75.5,
"continueAsking": true,
"followUpQuestion": "具体的追问问题(如果不追问则为空字符串)"
}
"""
);
return new Prompt(List.of(
promptTemplate.createMessage(params)
));
}
/**
* 获取最终报告
*
* @param params 参数
* @return 提示
*/
public static Prompt getFinalReportPrompt(Map<String, Object> params) {
// SystemMessage systemMessage = new SystemMessage("""
// 你是一位资深的技术面试官和HR专家具备丰富的候选人评估经验。你的职责是
//
// ## 核心职能:
// - 基于面试数据进行客观、全面的技术能力评估
// - 提供标准化的面试结果分析和建议
// - 支持招聘决策制定
//
// ## 评估原则:
// 1. **客观性**:基于实际表现数据,避免主观推测
// 2. **全面性**:覆盖技术能力、沟通表达、问题解决等维度
// 3. **标准化**:使用统一的评分体系和输出格式
// 4. **实用性**:提供可执行的改进建议和明确的录用建议
//
// ## 输出要求:
// 严格按照JSON格式输出不得包含任何markdown标记或额外解释
//
// {
// "overallScore": <1-100整数>,
// "overallFeedback": "<综合评价客观描述候选人整体表现150-200字>",
// "technicalAssessment": {
// "Java基础": "掌握良好,对集合框架理解深入。",
// "Spring框架": "熟悉基本使用,但对底层原理理解不足。",
// "数据库": "能够编写常规SQL但在索引优化方面知识欠缺。",
// "<技术领域1>": "<该领域的具体评估>",
// ...
// },
// "strengthsAndWeaknesses": {
// "strengths": ["<具体优势1>", "<具体优势2>", "<具体优势3>"],
// "weaknesses": ["<具体不足1>", "<具体不足2>", "<具体不足3>"]
// },
// "suggestions": [
// "<具体可执行的改进建议1>",
// "<具体可执行的改进建议2>",
// "<具体可执行的改进建议3>",
// "<具体可执行的改进建议4>",
// "<具体可执行的改进建议5>"
// ],
// "hiringRecommendation": "<强烈推荐|推荐|待考虑|不推荐>",
// "hiringReason": "<录用建议的具体理由50-80字>"
// }
//
// ## 评分标准:
// - 90-100分技术优秀表达清晰思维敏捷超出岗位要求
// - 80-89分技术良好基本满足岗位要求有培养潜力
// - 70-79分技术一般需要指导和培养
// - 60-69分技术较弱存在明显知识盲区
// - 60分以下技术不足不符合岗位要求
// """);
PromptTemplate promptTemplate = new PromptTemplate("""
请根据以下候选人信息和面试记录,生成标准化的面试评估报告:
## 核心职能:
- 基于面试数据进行客观、全面的技术能力评估
- 提供标准化的面试结果分析和建议
- 支持招聘决策制定
## 评估原则:
1. **客观性**:基于实际表现数据,避免主观推测
2. **全面性**:覆盖技术能力、沟通表达、问题解决等维度
3. **标准化**:使用统一的评分体系和输出格式
4. **实用性**:提供可执行的改进建议和明确的录用建议
## 输出要求:
严格按照JSON格式输出不得包含任何markdown标记或额外解释
{jsonRes}
## 评分标准:
- 90-100分技术优秀表达清晰思维敏捷超出岗位要求
- 80-89分技术良好基本满足岗位要求有培养潜力
- 70-79分技术一般需要指导和培养
- 60-69分技术较弱存在明显知识盲区
- 60分以下技术不足不符合岗位要求
## 候选人简历信息:
{resume}
## 完整面试记录:
{history}
请开始评估并输出JSON格式的报告。
""");
params.put("jsonRes", """
{
"overallScore": <1-100整数>,
"overallFeedback": "<综合评价客观描述候选人整体表现150-200字>",
"technicalAssessment": {
"Java基础": "掌握良好,对集合框架理解深入。",
"Spring框架": "熟悉基本使用,但对底层原理理解不足。",
"数据库": "能够编写常规SQL但在索引优化方面知识欠缺。",
"<技术领域1>": "<该领域的具体评估>",
...
},
"strengthsAndWeaknesses": {
"strengths": ["<具体优势1>", "<具体优势2>", "<具体优势3>"],
"weaknesses": ["<具体不足1>", "<具体不足2>", "<具体不足3>"]
},
"suggestions": [
"<具体可执行的改进建议1>",
"<具体可执行的改进建议2>",
"<具体可执行的改进建议3>",
"<具体可执行的改进建议4>",
"<具体可执行的改进建议5>"
],
"hiringRecommendation": "<强烈推荐|推荐|待考虑|不推荐>",
"hiringReason": "<录用建议的具体理由50-80字>"
}
""");
return new Prompt(List.of(
promptTemplate.createMessage(params)
));
}
}

View File

@@ -6,12 +6,12 @@ import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
@Component
public class SpringApplicationContextUtil implements ApplicationContextAware {
public class SpringApplicationContextUtils implements ApplicationContextAware {
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
SpringApplicationContextUtil.applicationContext = applicationContext;
SpringApplicationContextUtils.applicationContext = applicationContext;
}
public static <T> T getBean(Class<T> beanClass) {

View File

@@ -0,0 +1,63 @@
package com.qingqiu.interview.common.utils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
public class TreeUtils {
/**
* 通用树形结构构建方法
*/
public static <T, ID> List<T> buildTree(List<T> list,
Function<T, ID> idGetter,
Function<T, ID> parentIdGetter,
Function<T, List<T>> childrenSetter,
ID rootParentId) {
if (list == null || list.isEmpty()) {
return Collections.emptyList();
}
// 按父ID分组
Map<ID, List<T>> parentMap = list.stream()
.collect(Collectors.groupingBy(parentIdGetter));
// 设置子节点
list.forEach(item -> {
List<T> children = parentMap.get(idGetter.apply(item));
if (children != null && !children.isEmpty()) {
childrenSetter.apply(item).addAll(children);
}
});
// 返回根节点
return parentMap.get(rootParentId);
}
/**
* 扁平化树形结构
*/
public static <T> List<T> flattenTree(List<T> tree, Function<T, List<T>> childrenGetter) {
List<T> result = new ArrayList<>();
flattenTreeRecursive(tree, childrenGetter, result);
return result;
}
private static <T> void flattenTreeRecursive(List<T> nodes,
Function<T, List<T>> childrenGetter,
List<T> result) {
if (nodes == null) return;
for (T node : nodes) {
result.add(node);
List<T> children = childrenGetter.apply(node);
if (children != null && !children.isEmpty()) {
flattenTreeRecursive(children, childrenGetter, result);
}
}
}
}

View File

@@ -0,0 +1,8 @@
package com.qingqiu.interview.common.utils;
public class UUIDUtils {
public static String getUUID() {
return java.util.UUID.randomUUID().toString().replace("-", "");
}
}

View File

@@ -0,0 +1,46 @@
package com.qingqiu.interview.common.utils;
import io.netty.channel.ChannelOption;
import org.springframework.http.client.ReactorClientHttpRequestFactory;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
/**
* @program: ai-interview
* @description:
* @author: huangpeng
* @create: 2025-11-06 20:04
**/
public class WebClientUtils {
/**
* 创建全局默认的WebClient Bean实例
* 配置了连接超时和响应超时时间
*
* @return WebClient实例
*/
public static WebClient.Builder getWebClientBuilder() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 30000) // 连接超时
.responseTimeout(Duration.ofMillis(30000)); // 读取超时
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
;
}
public static RestClient.Builder getRestClientBuilder() {
return RestClient.builder()
.requestFactory(
new ReactorClientHttpRequestFactory(
HttpClient.create()
.responseTimeout(Duration.ofMinutes(30))
)
);
}
}

View File

@@ -0,0 +1,73 @@
package com.qingqiu.interview.config;
import com.alibaba.cloud.ai.memory.jdbc.MysqlChatMemoryRepository;
import com.alibaba.cloud.ai.memory.redis.RedisChatMemoryRepository;
import com.qingqiu.interview.common.constants.ChatConstant;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
import static com.qingqiu.interview.common.constants.ChatConstant.MAX_MESSAGES;
/**
* 聊天记忆相关配置
*/
@Configuration
public class ChatMemoryConfig {
@Value("${spring.ai.memory.redis.host}")
private String redisHost;
@Value("${spring.ai.memory.redis.port}")
private int redisPort;
@Value("${spring.ai.memory.redis.password}")
private String redisPassword;
@Value("${spring.ai.memory.redis.timeout}")
private int redisTimeout;
@Resource
private DataSource dataSource;
@Bean
public MysqlChatMemoryRepository mysqlChatMemoryRepository() {
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
return MysqlChatMemoryRepository.mysqlBuilder()
.jdbcTemplate(jdbcTemplate)
.build();
}
@Primary
@Bean(name = ChatConstant.MYSQL_CHAT_MEMORY_BEAN_NAME)
public MessageWindowChatMemory mysqlChatMemory(MysqlChatMemoryRepository mysqlChatMemoryRepository) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(mysqlChatMemoryRepository)
.maxMessages(MAX_MESSAGES)
.build();
}
@Bean
public RedisChatMemoryRepository redisChatMemoryRepository() {
return RedisChatMemoryRepository.builder()
.host(redisHost)
.port(redisPort)
// 若没有设置密码则注释该项
.password(redisPassword)
.timeout(redisTimeout)
.build();
}
@Bean(name = ChatConstant.REDIS_CHAT_MEMORY_BEAN_NAME)
public MessageWindowChatMemory redisChatMemory(RedisChatMemoryRepository redisChatMemoryRepository) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(redisChatMemoryRepository)
.maxMessages(MAX_MESSAGES)
.build();
}
}

View File

@@ -0,0 +1,22 @@
package com.qingqiu.interview.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**").
allowedOriginPatterns("*") //允许跨域的域名,可以用*表示允许任何域名使用
// allowedOrigins("*"). //在Springboot2.4对应Spring5.3后在设置allowCredentials(true)的基础上不能直接使用通配符设置allowedOrigins而是需要指定特定的URL。如果需要设置通配符需要通过allowedOriginPatterns指定
.allowedMethods("GET", "POST", "DELETE", "PUT") //允许任何方法post、get等
.allowedHeaders("*") //允许任何请求头
.allowCredentials(true) //带上cookie信息
.exposedHeaders(HttpHeaders.SET_COOKIE).maxAge(3600L); //maxAge(3600)表明在3600秒内不需要再发送预检验请求可以缓存该结果
}
}

View File

@@ -1,14 +1,120 @@
package com.qingqiu.interview.config;
import com.alibaba.dashscope.aigc.generation.Generation;
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
import com.alibaba.cloud.ai.dashscope.api.DashScopeResponseFormat;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import com.qingqiu.interview.common.constants.ChatConstant;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import static com.qingqiu.interview.common.utils.WebClientUtils.getRestClientBuilder;
import static com.qingqiu.interview.common.utils.WebClientUtils.getWebClientBuilder;
/**
* DashScope相关配置类
* 主要配置阿里云百炼平台(DashScope)和OpenAI的大模型服务
* 包括API客户端、聊天模型和聊天客户端的配置
*
* @author qingqiu
*/
@Configuration
public class DashScopeConfig {
// 从配置文件中读取DashScope API密钥
@Value("${spring.ai.dashscope.api-key}")
private String dashScopeApiKey;
@Value("${spring.ai.dashscope.chat.options.model}")
private String dashScopeChatModelName;
@Resource(name = ChatConstant.MYSQL_CHAT_MEMORY_BEAN_NAME)
private MessageWindowChatMemory mysqlChatMemory;
/**
* 创建DashScopeApi Bean实例
* 配置了HTTP客户端连接参数包括连接超时和响应超时时间
*
* @return DashScopeApi实例
*/
@Bean
public Generation generation() {
return new Generation();
public DashScopeApi dashScopeApi() {
return DashScopeApi.builder()
.apiKey(dashScopeApiKey)
.restClientBuilder(getRestClientBuilder())
.webClientBuilder(getWebClientBuilder())
.build();
}
}
/**
* 创建DashScope聊天模型Bean实例
* 配置了最大token数、使用的模型以及返回格式为JSON对象
*
* @return ChatModel实例
*/
@Bean(name = ChatConstant.DASH_SCOPE_CHAT_MODEL_BEAN_NAME)
public ChatModel dashScopeChatModel() {
return DashScopeChatModel.builder()
.dashScopeApi(dashScopeApi())
.defaultOptions(
DashScopeChatOptions.builder()
.withMaxToken(ChatConstant.MAX_COMPLETION_TOKENS)
.withModel(dashScopeChatModelName)
.withResponseFormat(
DashScopeResponseFormat.builder()
.type(DashScopeResponseFormat.Type.JSON_OBJECT)
.build()
)
.build()
)
.build();
}
/**
* 创建默认的聊天客户端Bean实例使用DashScope模型
* 添加了日志记录功能
*
* @return ChatClient实例
*/
@Bean
@Primary
public ChatClient chatClient() {
return ChatClient
.builder(dashScopeChatModel())
.defaultAdvisors(
new SimpleLoggerAdvisor()
)
.build();
}
/**
* 创建基于MySQL存储聊天历史的DashScope聊天客户端Bean实例
* 配置了消息内存管理和日志记录功能
*
* @return ChatClient实例
*/
@Bean(name = ChatConstant.DASH_SCOPE_CHAT_CLIENT_BEAN_NAME)
public ChatClient dashScopeChatClient() {
return ChatClient
.builder(dashScopeChatModel())
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(mysqlChatMemory).build(),
new SimpleLoggerAdvisor()
)
.build();
}
}

View File

@@ -0,0 +1,57 @@
package com.qingqiu.interview.config;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONReader;
import com.alibaba.fastjson2.JSONWriter;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
public class Fastjson2RedisSerializer<T> implements RedisSerializer<T> {
// 默认编码
public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
// 泛型类型,用于反序列化
private final Class<T> clazz;
public Fastjson2RedisSerializer(Class<T> clazz) {
super();
this.clazz = clazz;
}
/**
* 序列化:将对象转换为字节数组
*/
@Override
public byte[] serialize(T t) throws SerializationException {
if (t == null) {
return new byte[0];
}
try {
// 使用 Fastjson2 序列化对象,并写入字节数组
// 配置写入特性WriteClassName 确保反序列化时能识别类型,如果不需要,可以移除。
return JSON.toJSONBytes(t, JSONWriter.Feature.WriteClassName);
} catch (Exception ex) {
throw new SerializationException("Could not serialize object with Fastjson2", ex);
}
}
/**
* 反序列化:将字节数组转换为对象
*/
@Override
public T deserialize(byte[] bytes) throws SerializationException {
if (bytes == null || bytes.length <= 0) {
return null;
}
try {
// 使用 Fastjson2 反序列化字节数组
// 配置读取特性SupportAutoType确保可以正确读取带有类名信息的JSON
return JSON.parseObject(bytes, clazz, JSONReader.Feature.SupportAutoType);
} catch (Exception ex) {
throw new SerializationException("Could not deserialize object with Fastjson2", ex);
}
}
}

View File

View File

View File

@@ -0,0 +1,100 @@
package com.qingqiu.interview.config;
import com.qingqiu.interview.common.constants.ChatConstant;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.api.ResponseFormat;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import static com.qingqiu.interview.common.utils.WebClientUtils.getRestClientBuilder;
import static com.qingqiu.interview.common.utils.WebClientUtils.getWebClientBuilder;
/**
* @program: ai-interview
* @description: openai聊天配置
* @author: huangpeng
* @create: 2025-11-06 20:03
**/
@Configuration
public class OpenAiChatConfig {
@Value("${spring.ai.openai.chat.options.model}")
private String openAiChatModelName;
@Value("${spring.ai.openai.base-url}")
private String openAiBaseUrl;
@Value("${spring.ai.openai.api-key}")
private String openAiApiKey;
@Resource(name = ChatConstant.MYSQL_CHAT_MEMORY_BEAN_NAME)
private MessageWindowChatMemory mysqlChatMemory;
/**
* 创建OpenAI聊天模型Bean实例主模型
* 配置了最大完成token数和响应格式为JSON Schema
*
* @return ChatModel实例
*/
@Bean(name = ChatConstant.OPEN_AI_CHAT_MODEL_BEAN_NAME)
@Primary
public ChatModel openAiChatModel() {
return OpenAiChatModel.builder()
.openAiApi(
OpenAiApi.builder()
.apiKey(openAiApiKey)
.baseUrl(openAiBaseUrl)
.webClientBuilder(getWebClientBuilder())
.restClientBuilder(getRestClientBuilder())
.build()
)
.defaultOptions(
OpenAiChatOptions.builder()
.model(openAiChatModelName)
.maxCompletionTokens(ChatConstant.MAX_COMPLETION_TOKENS)
.responseFormat(
ResponseFormat.builder()
.type(ResponseFormat.Type.JSON_SCHEMA)
.jsonSchema(
ResponseFormat.JsonSchema
.builder()
.build()
).build()
)
.build()
)
.build();
}
/**
* 创建基于MySQL存储聊天历史的OpenAI聊天客户端Bean实例
* 配置了消息内存管理和日志记录功能
*
* @return ChatClient实例
*/
@Bean(name = ChatConstant.OPEN_AI_CHAT_CLIENT_BEAN_NAME)
public ChatClient openAiChatClient() {
return ChatClient
.builder(openAiChatModel())
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(mysqlChatMemory).build(),
new SimpleLoggerAdvisor()
)
.build();
}
}

View File

@@ -0,0 +1,32 @@
package com.qingqiu.interview.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
// 使用StringRedisSerializer来序列化和反序列化redis的key值
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
template.setKeySerializer(stringRedisSerializer);
template.setHashKeySerializer(stringRedisSerializer);
// 使用Fastjson2RedisSerializer来序列化和反序列化redis的value值
Fastjson2RedisSerializer<Object> fastjson2RedisSerializer = new Fastjson2RedisSerializer<>(Object.class);
template.setValueSerializer(fastjson2RedisSerializer);
template.setHashValueSerializer(fastjson2RedisSerializer);
template.afterPropertiesSet();
return template;
}
}

View File

View File

@@ -1,10 +1,23 @@
package com.qingqiu.interview.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.qingqiu.interview.common.res.R;
import com.qingqiu.interview.entity.AiSessionLog;
import com.qingqiu.interview.service.IAiSessionLogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* <p>
* ai会话记录 前端控制器
@@ -13,8 +26,22 @@ import org.springframework.web.bind.annotation.RestController;
* @author huangpeng
* @since 2025-08-30
*/
@Slf4j
@RestController
@RequestMapping("/ai-session-log")
@RequiredArgsConstructor(onConstructor_ = {@Autowired, @Lazy})
public class AiSessionLogController {
private final IAiSessionLogService service;
@GetMapping("/list-by-session-id/{sessionId}")
public R<List<AiSessionLog>> list(@PathVariable String sessionId) {
return R.success(service.list(
new LambdaQueryWrapper<AiSessionLog>()
.eq(AiSessionLog::getToken, sessionId)
.ne(AiSessionLog::getRole, MessageType.SYSTEM.getValue())
));
}
}

View File

@@ -0,0 +1,36 @@
package com.qingqiu.interview.controller;
import com.qingqiu.interview.common.res.R;
import com.qingqiu.interview.dto.ChatDTO;
import com.qingqiu.interview.dto.InterviewStartRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/**
* <h1>AI聊天控制器</h1>
*
* @author qingqiu
* @date 2025/9/18 12:11
*/
@RestController
@RequestMapping("/chat")
@RequiredArgsConstructor
public class ChatController {
/**
* 创建聊天
* @return
*/
@PostMapping("/send")
public R<?> createChat(@RequestBody ChatDTO dto) {
return R.success();
}
@PostMapping("/interview/create")
public R<?> createInterview(@RequestParam("resume") MultipartFile resume,
@Validated @ModelAttribute InterviewStartRequest request) {
return R.success();
}
}

View File

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

View File

@@ -1,58 +1,133 @@
package com.qingqiu.interview.controller;
import com.qingqiu.interview.dto.*;
import com.qingqiu.interview.entity.InterviewSession;
import com.qingqiu.interview.service.InterviewService;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
/**
* 面试流程相关接口
*/
@RestController
@RequestMapping("/api/v1/interview")
@RequiredArgsConstructor
public class InterviewController {
private final InterviewService interviewService;
/**
* 开始新的面试会话
*/
@PostMapping("/start")
public ApiResponse<InterviewResponse> startInterview(
@RequestParam("resume") MultipartFile resume,
@Validated @ModelAttribute InterviewStartRequest request) throws IOException {
InterviewResponse response = interviewService.startInterview(resume, request);
return ApiResponse.success(response);
}
/**
* 继续面试会话(用户回答)
*/
@PostMapping("/chat")
public ApiResponse<InterviewResponse> continueInterview(@Validated @RequestBody ChatRequest request) {
InterviewResponse response = interviewService.continueInterview(request);
return ApiResponse.success(response);
}
/**
* 获取所有面试会话列表
*/
@PostMapping("/get-history-list")
public ApiResponse<java.util.List<InterviewSession>> getInterviewHistoryList() {
return ApiResponse.success(interviewService.getInterviewSessions());
}
/**
* 获取单次面试的详细复盘报告
*/
@PostMapping("/get-report-detail")
public ApiResponse<InterviewReportResponse> getInterviewReportDetail(@RequestBody SessionRequest request) {
return ApiResponse.success(interviewService.getInterviewReport(request.getSessionId()));
}
}
package com.qingqiu.interview.controller;
import com.qingqiu.interview.common.res.R;
import com.qingqiu.interview.dto.*;
import com.qingqiu.interview.entity.InterviewMessage;
import com.qingqiu.interview.entity.InterviewQuestionProgress;
import com.qingqiu.interview.entity.InterviewSession;
import com.qingqiu.interview.service.InterviewService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
/**
* <h1></h1>
*
* @author qingqiu
* @date 2025/9/19 16:13
*/
@Slf4j
@RestController
@RequestMapping("/interview")
@RequiredArgsConstructor(onConstructor_ = {@Autowired, @Lazy})
public class InterviewController {
private final InterviewService interviewService;
/**
* 开始面试
*
* @return 包含会话ID的会话信息
*/
@PostMapping("/start")
public R<InterviewSession> start(@RequestPart("resume") MultipartFile resume,
@RequestPart("interviewStartDto") InterviewStartRequest request) {
// log.info("接受的数据: {}", JSONObject.toJSONString(request));
// return R.success();
try {
InterviewSession session = interviewService.startInterview(resume, request);
return R.success(session);
} catch (Exception e) {
log.error("开始面试失败", e);
return R.error("开始面试失败:" + e.getMessage());
}
}
/**
* 获取下一个问题
*
* @param sessionId 会话ID
* @return 下一个问题
*/
@GetMapping("/next-question/{sessionId}/{progressId}")
public R<InterviewMessage> getNextQuestion(@PathVariable String sessionId,
@PathVariable Long progressId) {
try {
InterviewMessage nextQuestion = interviewService.getNextQuestion(sessionId, progressId);
if (nextQuestion == null) {
return R.success(null, "所有问题已回答完毕!");
}
return R.success(nextQuestion);
} catch (Exception e) {
// log.error("获取下一题失败", e);
return R.error("获取下一题失败:" + e.getMessage());
}
}
/**
* 提交答案
*
* @param submitDto 包含进度ID和答案
* @return 对当前问题的评估
*/
@PostMapping("/submit-answer")
public R<InterviewQuestionProgress> submitAnswer(@RequestBody SubmitAnswerDTO submitDto) {
try {
InterviewQuestionProgress result = interviewService.submitAnswer(submitDto);
return R.success(result);
} catch (Exception e) {
// log.error("提交答案失败", e);
return R.error("提交答案失败:" + e.getMessage());
}
}
/**
* 结束面试并获取最终报告
*
* @param sessionId 会话ID
* @return 包含最终报告的会话信息
*/
@PostMapping("/{sessionId}/end")
public R<InterviewSession> endInterview(@PathVariable String sessionId) {
try {
InterviewSession finalSession = interviewService.endInterview(sessionId);
return R.success(finalSession);
} catch (Exception e) {
// log.error("结束面试失败", e);
return R.error("结束面试失败:" + e.getMessage());
}
}
@PostMapping("/get-history-list")
public R<List<InterviewSession>> getHistoryList() {
try {
List<InterviewSession> historyList = interviewService.list();
return R.success(historyList);
} catch (Exception e) {
// log.error("获取面试历史列表失败", e);
return R.error("获取面试历史列表失败:" + e.getMessage());
}
}
/**
* 获取单次面试的详细复盘报告
*/
@PostMapping("/get-report-detail/{sessionId}")
public R<InterviewReportResponse> getInterviewReportDetail(@PathVariable String sessionId) {
return R.success(interviewService.getInterviewReport(sessionId));
}
@PostMapping("/read-pdf")
public R<?> readPdf(@RequestParam MultipartFile file) throws IOException {
String readPdfFile = interviewService.readPdfFile(file);
log.info("resume content: {}", readPdfFile);
return R.success(readPdfFile);
}
}

View File

@@ -0,0 +1,42 @@
package com.qingqiu.interview.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.qingqiu.interview.common.res.R;
import com.qingqiu.interview.entity.InterviewMessage;
import com.qingqiu.interview.service.InterviewMessageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* <h1></h1>
*
* @author qingqiu
* @date 2025/9/21 11:59
*/
@Slf4j
@RestController
@RequestMapping("/interview-message")
@RequiredArgsConstructor(onConstructor_ = {@Autowired, @Lazy})
public class InterviewMessageController {
public final InterviewMessageService service;
@GetMapping("/list-by-session-id/{sessionId}")
public R<List<InterviewMessage>> listBySessionId(@PathVariable String sessionId) {
return R.success(
service.list(
new LambdaQueryWrapper<InterviewMessage>()
.eq(InterviewMessage::getSessionId, sessionId)
.orderByAsc(InterviewMessage::getCreatedTime)
)
);
}
}

View File

@@ -3,12 +3,10 @@ package com.qingqiu.interview.controller;
import com.qingqiu.interview.common.res.R;
import com.qingqiu.interview.dto.QuestionProgressPageParams;
import com.qingqiu.interview.entity.InterviewQuestionProgress;
import com.qingqiu.interview.service.IInterviewQuestionProgressService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
/**
* <p>
@@ -19,7 +17,7 @@ import org.springframework.web.bind.annotation.RestController;
* @since 2025-08-30
*/
@RestController
@RequestMapping("/api/v1/interview-question-progress")
@RequestMapping("/interview-question-progress")
@RequiredArgsConstructor
public class InterviewQuestionProgressController {
@@ -27,6 +25,7 @@ public class InterviewQuestionProgressController {
/**
* 面试问题进度列表
*
* @param params 查询参数
* @return data
*/
@@ -35,4 +34,16 @@ public class InterviewQuestionProgressController {
return R.success(service.pageList(params));
}
/**
* 获取面试问题进度详情
*
* @param id id
* @return data
*/
@GetMapping("/{id}")
public R<InterviewQuestionProgress> getDetail(@PathVariable Long id) {
return R.success(service.getById(id));
}
}

View File

@@ -0,0 +1,162 @@
package com.qingqiu.interview.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.qingqiu.interview.common.res.R;
import com.qingqiu.interview.dto.QuestionCategoryDTO;
import com.qingqiu.interview.dto.QuestionCategoryPageParams;
import com.qingqiu.interview.entity.QuestionCategory;
import com.qingqiu.interview.service.IQuestionCategoryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/question-category")
@RequiredArgsConstructor
public class QuestionCategoryController {
@Lazy
private final IQuestionCategoryService questionCategoryService;
/**
* 获取分类树列表
*/
@GetMapping("/tree-list")
public R<List<QuestionCategory>> getTreeList() {
List<QuestionCategory> list = questionCategoryService.getTreeList();
return R.success(list);
}
@GetMapping("/question-tree-list")
public R<List<QuestionCategory>> getQuestionTreeList() {
// List<QuestionCategory> list = questionCategoryService.getQuestionTreeList();
return R.success();
}
/**
* 获取分类选项
*/
@GetMapping("/options")
public R<List<QuestionCategory>> getOptions() {
try {
List<QuestionCategory> options = questionCategoryService.getOptions();
return R.success(options);
} catch (Exception e) {
log.error("获取分类选项失败", e);
return R.error("获取分类选项失败");
}
}
/**
* 获取分类详情
*/
@GetMapping("/{id}")
public R<QuestionCategory> getDetail(@PathVariable Long id) {
try {
QuestionCategory category = questionCategoryService.getCategoryDetail(id);
return R.success(category);
} catch (Exception e) {
log.error("获取分类详情失败", e);
return R.error("获取分类详情失败");
}
}
/**
* 分页查询分类
*/
@GetMapping("/page")
public R<Page<QuestionCategory>> getPage(QuestionCategoryPageParams query) {
try {
Page<QuestionCategory> pageR = questionCategoryService.getCategoryPage(query);
return R.success(pageR);
} catch (Exception e) {
log.error("分页查询分类失败", e);
return R.error("分页查询分类失败");
}
}
/**
* 创建分类
*/
@PostMapping
public R<Long> create(@Validated @RequestBody QuestionCategoryDTO dto) {
try {
Long id = questionCategoryService.createCategory(dto);
return R.success(id);
} catch (RuntimeException e) {
log.error("创建分类失败", e);
return R.error(e.getMessage());
} catch (Exception e) {
log.error("创建分类失败", e);
return R.error("创建分类失败");
}
}
/**
* 更新分类
*/
@PostMapping("/update")
public R<Void> update(@RequestBody QuestionCategoryDTO dto) {
try {
questionCategoryService.updateCategory(dto);
return R.success();
} catch (RuntimeException e) {
log.error("更新分类失败", e);
return R.error(e.getMessage());
} catch (Exception e) {
log.error("更新分类失败", e);
return R.error("更新分类失败");
}
}
/**
* 删除分类
*/
@DeleteMapping("/{id}")
public R<Void> delete(@PathVariable Long id) {
try {
questionCategoryService.deleteCategory(id);
return R.success();
} catch (RuntimeException e) {
log.error("删除分类失败", e);
return R.error(e.getMessage());
} catch (Exception e) {
log.error("删除分类失败", e);
return R.error("删除分类失败");
}
}
/**
* 更新分类状态
*/
@PatchMapping("/{id}/state")
public R<Void> updateState(@PathVariable Long id, @RequestParam Integer state) {
try {
questionCategoryService.updateState(id, state);
return R.success();
} catch (Exception e) {
log.error("更新分类状态失败", e);
return R.error("更新分类状态失败");
}
}
/**
* 搜索分类
*/
@GetMapping("/search")
public R<List<QuestionCategory>> search(@RequestParam String name) {
try {
List<QuestionCategory> res = questionCategoryService.searchByName(name);
return R.success(res);
} catch (Exception e) {
log.error("搜索分类失败", e);
return R.error("搜索分类失败");
}
}
}

View File

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

View File

View File

@@ -0,0 +1,28 @@
package com.qingqiu.interview.dto;
import com.qingqiu.interview.common.enums.LLMProvider;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* <h1></h1>
*
* @author qingqiu
* @date 2025/9/18 12:54
*/
@Data
@Accessors(chain = true)
public class ChatDTO {
/** 会话id */
private String sessionId;
/** 调用模型 */
private String aiModel = LLMProvider.DEEPSEEK.getCode();
/** 输入内容 */
private String content;
/** 0 普通会话 1 面试会话 */
private Integer dataType;
/** 角色类型user/assistant/system */
private String role;
}

View File

View File

View File

View File

View File

@@ -1,15 +1,34 @@
package com.qingqiu.interview.dto;
import com.qingqiu.interview.common.enums.LLMProvider;
import com.qingqiu.interview.vo.QuestionAndCategoryTreeListVO;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.List;
@Data
public class InterviewStartRequest {
@NotBlank(message = "候选人姓名不能为空")
private String candidateName;
private List<QuestionAndCategoryTreeListVO> selectedNodes;
@NotBlank(message = "面试类型不能为空")
private String model;
/** 选择的AI模型 */
private String aiModel = LLMProvider.QWEN.getCode();
/** 生成的面试题目数量 */
private Integer totalQuestions = 10;
/**
* 岗位要求
*/
private String jobRequirements;
// 简历文件通过MultipartFile单独传递
}

View File

@@ -0,0 +1,38 @@
package com.qingqiu.interview.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* <h1></h1>
*
* @author huangpeng
* @date 2025/9/11 09:39
*/
@Data
public class QuestionCategoryDTO {
private Long id;
@NotBlank(message = "分类名称不能为空")
private String name;
@NotNull(message = "父级分类ID不能为空")
private Long parentId;
@NotNull(message = "排序不能为空")
private Integer sort;
@NotNull(message = "状态不能为空")
private Integer state;
private String ancestor;
private Integer level;
/**
* 父分类名称(用于前端显示)
*/
private String parentName;
}

View File

@@ -0,0 +1,48 @@
package com.qingqiu.interview.dto;
import com.qingqiu.interview.common.dto.PageBaseParams;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/**
* <h1></h1>
*
* @author huangpeng
* @date 2025/9/11 09:40
*/
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
public class QuestionCategoryPageParams extends PageBaseParams {
/**
* 分类名称(模糊查询)
*/
private String name;
/**
* 状态0禁用1启用
*/
private Integer state;
/**
* 父级分类ID
*/
private Long parentId;
/**
* 层级
*/
private Integer level;
/**
* 是否包含子分类
*/
private Boolean includeChildren = false;
/**
* 是否只返回启用状态的分类
*/
private Boolean onlyEnabled = false;
}

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

@@ -1,5 +1,6 @@
package com.qingqiu.interview.dto;
import com.qingqiu.interview.common.dto.PageBaseParams;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
@@ -8,7 +9,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;
}

View File

@@ -1,5 +1,6 @@
package com.qingqiu.interview.dto;
import com.qingqiu.interview.common.dto.PageBaseParams;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
@@ -7,6 +8,6 @@ import lombok.experimental.Accessors;
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
public class QuestionProgressPageParams extends PageBaseParams{
public class QuestionProgressPageParams extends PageBaseParams {
private String questionName;
}

View File

View File

View File

@@ -0,0 +1,43 @@
package com.qingqiu.interview.dto;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serial;
import java.io.Serializable;
/**
* <h1>
* 开始面试请求的数据传输对象
* </h1>
*
* @author qingqiu
* @date 2025/9/19 16:03
*/
@Data
@Accessors(chain = true)
public class StartInterviewDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 候选人姓名
*/
private String candidateName;
/**
* 简历完整内容或简历文件URL
*/
private String resumeContent;
/**
* 指定使用的AI模型
*/
private String aiModel;
/**
* 计划提问总数
*/
private Integer totalQuestions;
}

View File

@@ -0,0 +1,33 @@
package com.qingqiu.interview.dto;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serial;
import java.io.Serializable;
/**
* <h1></h1>
*
* @author qingqiu
* @date 2025/9/19 16:04
*/
@Data
@Accessors(chain = true)
public class SubmitAnswerDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String sessionId;
/**
* 当前问题的进度ID (interview_question_progress.id)
*/
private Long progressId;
/**
* 用户的回答内容
*/
private String answer;
}

View File

@@ -5,6 +5,7 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
@@ -22,6 +23,7 @@ import java.time.LocalDateTime;
@TableName("ai_session_log")
public class AiSessionLog implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
@@ -32,6 +34,11 @@ public class AiSessionLog implements Serializable {
*/
private String role;
/**
* 数据类型 0 普通会话 1 面试会话
*/
private Integer dataType;
/**
* 输入内容
*/
@@ -54,5 +61,8 @@ public class AiSessionLog implements Serializable {
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedTime;
@TableLogic
private Integer deleted;
}

View File

View File

@@ -28,8 +28,8 @@ public class InterviewMessage {
@TableField("content")
private String content;
@TableField("question_id")
private Long questionId;
@TableField("question_progress_id")
private Long questionProgressId;
@TableField("message_order")
private Integer messageOrder;

View File

@@ -33,6 +33,15 @@ public class InterviewQuestionProgress {
@TableField("question_content")
private String questionContent;
/** 问题序号 */
private Integer questionIndex;
/** 答题耗时(秒) */
private Long timeTaken;
/** 详细评估信息 */
private String evaluationDetails;
/**
* 面试会话ID
*/

View File

@@ -31,11 +31,24 @@ public class InterviewSession implements Serializable {
@TableField("resume_content")
private String resumeContent;
@TableField("job_requirements")
private String jobRequirements;
@TableField("extracted_skills")
private String extractedSkills;
@TableField("interview_type")
private String interviewType;
@TableField("estimated_duration")
private Integer estimatedDuration;
@TableField("current_question_id")
private Long currentQuestionId;
@TableField("ai_model")
private String aiModel;
@TableField("model")
private String model;
@TableField("status")
private String status;

View File

@@ -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;

View File

@@ -1,14 +1,14 @@
package com.qingqiu.interview.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import java.time.LocalDateTime;
import java.io.Serializable;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.List;
/**
* <p>
* 题型分类
@@ -25,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;
/**
@@ -33,16 +33,60 @@ public class QuestionCategory implements Serializable {
*/
private String name;
/**
* 上级id
*/
private Long parentId;
/**
* 层级
*/
private Integer level;
/**
* 上级序列
*/
private String ancestor;
/**
* 排序
*/
private Integer sort;
/**
* 状态 0 禁用 1 启用
*/
private Integer state;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedTime;
@TableLogic
private Integer deleted;
/**
* 子分类列表(非数据库字段)
*/
@TableField(exist = false)
private List<QuestionCategory> children;
/**
* 子分类数量(非数据库字段)
*/
@TableField(exist = false)
private Integer childrenCount;
/**
* 父分类名称(非数据库字段,用于显示)
*/
@TableField(exist = false)
private String parentName;
}

View File

@@ -0,0 +1,11 @@
package com.qingqiu.interview.entity.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
public record EvaluateAiRes(@JsonProperty("feedback") String feedback,
@JsonProperty("suggestions") String suggestions,
@JsonProperty("aiAnswer") String aiAnswer,
@JsonProperty("score") double score,
@JsonProperty("continueAsking") boolean continueAsking,
@JsonProperty("followUpQuestion") String followUpQuestion) {
}

View File

@@ -0,0 +1,10 @@
package com.qingqiu.interview.entity.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public record ExtractSkillAiRes(
@JsonProperty("skills") List<String> skills
) {
}

View File

@@ -0,0 +1,27 @@
package com.qingqiu.interview.entity.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;
public record InterviewReportAiRes(
@JsonProperty("overallScore") String overallScore,
@JsonProperty("overallFeedback") String overallFeedback,
@JsonProperty("technicalAssessment") Map<String, String> technicalAssessment,
@JsonProperty("strengthsAndWeaknesses") StrengthsAndWeaknesses strengthsAndWeaknesses,
@JsonProperty("suggestions") List<String> suggestions,
@JsonProperty("hiringRecommendation") String hiringRecommendation,
@JsonProperty("hiringReason") String hiringReason
) {
/**
* 内部 Record对应 "strengthsAndWeaknesses" 对象。
*/
public record StrengthsAndWeaknesses(
@JsonProperty("strengths") List<String> strengths,
@JsonProperty("weaknesses") List<String> weaknesses
) {}
}

View File

@@ -0,0 +1,26 @@
package com.qingqiu.interview.entity.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public class QuestionAiRes {
/**
* 内部实体类:对应 JSON 数组中的每个元素。
* { "id": "ai-gen-1", "content": "问题内容..." }
*/
public record Question(
@JsonProperty("id") String id,
@JsonProperty("content") String content
) {}
/**
* 外部实体类:对应整个 JSON 响应的顶层结构。
* { "questions": [...] }
*/
public record Wrapper(
@JsonProperty("questions") List<Question> questions
) {}
}

View File

@@ -0,0 +1,22 @@
package com.qingqiu.interview.entity.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* @program: ai-interview
* @description: 题型分类ai返回
* @author: huangpeng
* @create: 2025-11-06 20:08
**/
public class QuestionClassificationAiRes {
public record Item(@JsonProperty("content") String content,
@JsonProperty("category") String category,
@JsonProperty("difficulty") String difficulty,
@JsonProperty("tags") String tags) {}
public record Wrapper(@JsonProperty("questions")List<Item> questions) {}
}

View File

@@ -0,0 +1,18 @@
package com.qingqiu.interview.entity.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import redis.clients.jedis.graph.Statistics;
import java.util.List;
/**
* @program: ai-interview
* @description: 题目去重AI返回结果
* @author: huangpeng
* @create: 2025-11-07 10:37
**/
public record QuestionDeduplicationAiRes(
@JsonProperty("questionIds") String questionIds
) {
}

View File

View File

View File

View File

@@ -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;
/**
* <p>
@@ -12,5 +15,5 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
* @since 2025-09-08
*/
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;
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> {
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,59 +1,15 @@
package com.qingqiu.interview.service;
import com.qingqiu.interview.mapper.InterviewSessionMapper;
import com.qingqiu.interview.mapper.QuestionMapper;
import com.qingqiu.interview.dto.DashboardStatsResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@Service
@RequiredArgsConstructor
public class DashboardService {
private final QuestionMapper questionMapper;
private final InterviewSessionMapper sessionMapper;
public DashboardStatsResponse getDashboardStats() {
DashboardStatsResponse stats = new DashboardStatsResponse();
// 1. 获取核心KPI
stats.setTotalQuestions(questionMapper.selectCount(null));
stats.setTotalInterviews(sessionMapper.selectCount(null));
// 2. 获取题库分类统计
stats.setQuestionCategoryStats(questionMapper.countByCategory());
// 3. 获取最近7天的面试统计并补全没有数据的日期
List<DashboardStatsResponse.DailyStat> recentStats = sessionMapper.countRecentInterviews(7);
stats.setRecentInterviewStats(fillMissingDates(recentStats, 7));
return stats;
}
/**
* 填充最近几天内没有面试数据的日期补0
*/
private List<DashboardStatsResponse.DailyStat> fillMissingDates(List<DashboardStatsResponse.DailyStat> existingStats, int days) {
Map<String, Long> statsMap = existingStats.stream()
.collect(Collectors.toMap(DashboardStatsResponse.DailyStat::getDate, DashboardStatsResponse.DailyStat::getCount));
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
return IntStream.range(0, days)
.mapToObj(i -> LocalDate.now().minusDays(i))
.map(date -> {
String dateString = date.format(formatter);
long count = statsMap.getOrDefault(dateString, 0L);
return new DashboardStatsResponse.DailyStat(dateString, count);
})
.sorted((d1, d2) -> d1.getDate().compareTo(d2.getDate())) // 按日期升序排序
.collect(Collectors.toList());
}
}
package com.qingqiu.interview.service;
import com.qingqiu.interview.dto.DashboardStatsResponse;
/**
* @program: ai-interview
* @description: 工作台接口
* @author: huangpeng
* @create: 2025-11-07 14:54
**/
public interface DashboardService {
DashboardStatsResponse getDashboardStats();
}

View File

View File

@@ -15,4 +15,6 @@ import com.qingqiu.interview.entity.InterviewQuestionProgress;
*/
public interface IInterviewQuestionProgressService extends IService<InterviewQuestionProgress> {
Page<InterviewQuestionProgress> pageList(QuestionProgressPageParams params);
InterviewQuestionProgress getNextQuestion(String sessionId);
}

View File

@@ -1,7 +1,12 @@
package com.qingqiu.interview.service;
import com.qingqiu.interview.entity.QuestionCategory;
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 java.util.List;
/**
* <p>
@@ -12,5 +17,59 @@ import com.baomidou.mybatisplus.extension.service.IService;
* @since 2025-09-08
*/
public interface IQuestionCategoryService extends IService<QuestionCategory> {
/**
* 获取分类树列表
*/
List<QuestionCategory> getTreeList();
/**
* 获取分类选项(用于下拉选择)
*/
List<QuestionCategory> getOptions();
/**
* 创建分类
*/
Long createCategory(QuestionCategoryDTO dto);
/**
* 更新分类
*/
void updateCategory(QuestionCategoryDTO dto);
/**
* 删除分类
*/
void deleteCategory(Long id);
/**
* 更新分类状态
*/
void updateState(Long id, Integer state);
/**
* 获取分类详情
*/
QuestionCategory getCategoryDetail(Long id);
/**
* 分页查询分类
*/
Page<QuestionCategory> getCategoryPage(QuestionCategoryPageParams query);
/**
* 根据名称搜索分类
*/
List<QuestionCategory> searchByName(String name);
/**
* 检查分类名称是否重复
*/
boolean checkNameExists(String name, Long parentId, Long excludeId);
List<QuestionCategory> batchFindByAncestorIdsUnion(List<Long> searchIds);
}

View File

@@ -0,0 +1,63 @@
package com.qingqiu.interview.service;
import com.qingqiu.interview.entity.InterviewQuestionProgress;
import com.qingqiu.interview.entity.InterviewSession;
import com.qingqiu.interview.entity.Question;
import com.qingqiu.interview.entity.ai.EvaluateAiRes;
import com.qingqiu.interview.entity.ai.InterviewReportAiRes;
import com.qingqiu.interview.entity.ai.QuestionAiRes;
import java.util.List;
/**
* <h1>
* 面试接入AI的接口
* </h1>
*
* @author qingqiu
* @date 2025/9/19 16:48
*/
public interface InterviewAiService {
/**
* 从简历内容中提取技能列表
*
* @param resumeContent 简历文本
* @return 包含技能列表的JSON对象
*/
List<String> extractSkillsFromResume(String resumeContent);
/**
* 根据技能动态生成面试题目
*
* @param skills 技能列表
* @param resumeContent 简历内容
* @param count 需要生成的题目数量
* @return 包含问题列表的JSON对象
*/
List<QuestionAiRes.Question> generateQuestionsOfAi(String sessionId, List<String> skills, String jobRequirements, String resumeContent, int count);
List<QuestionAiRes.Question> generateQuestionOfLocal(String sessionId, List<Question> questions, List<String> skills, String jobRequirements, String resumeContent, int count);
/**
* 评估用户的回答
*
* @param question 问题内容
* @param userAnswer 用户的回答
* @param context 可选的上下文(之前的问答历史)
* @return 包含评估结果的JSON对象
*/
EvaluateAiRes evaluateAnswer(String sessionId, String question, String userAnswer, List<InterviewQuestionProgress> context);
/**
* 生成最终的面试评估报告
*
* @param session 面试会话信息
* @param progressList 整个面试的问答记录
* @return 包含最终报告的JSON对象
*/
InterviewReportAiRes generateFinalReport(InterviewSession session, List<InterviewQuestionProgress> progressList);
String generateFirstQuestion(String sessionId, String candidateName, String questionContent);
}

View File

@@ -0,0 +1,17 @@
package com.qingqiu.interview.service;
import com.qingqiu.interview.dto.InterviewStartRequest;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
/**
* <h1></h1>
*
* @author qingqiu
* @date 2025/9/18 16:37
*/
public interface InterviewChatService {
void startInterview(MultipartFile resume, InterviewStartRequest request) throws IOException;
}

View File

@@ -0,0 +1,13 @@
package com.qingqiu.interview.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.qingqiu.interview.entity.InterviewMessage;
/**
* <h1></h1>
*
* @author qingqiu
* @date 2025/9/21 12:00
*/
public interface InterviewMessageService extends IService<InterviewMessage> {
}

View File

@@ -1,654 +1,70 @@
package com.qingqiu.interview.service;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.qingqiu.interview.dto.*;
import com.qingqiu.interview.entity.*;
import com.qingqiu.interview.mapper.*;
import com.qingqiu.interview.service.llm.LlmService;
import com.qingqiu.interview.service.parser.DocumentParser;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.qingqiu.interview.common.constants.QwenModelConstant.QWEN_MAX;
@Slf4j
@Service
@RequiredArgsConstructor
public class InterviewService {
private final LlmService llmService; // Changed to a single service
private final List<DocumentParser> documentParserList;
private final QuestionMapper questionMapper;
private final InterviewSessionMapper sessionMapper;
private final InterviewMessageMapper messageMapper;
private final InterviewEvaluationMapper evaluationMapper;
private final InterviewQuestionProgressMapper questionProgressMapper;
private final ObjectMapper objectMapper;
private Map<String, DocumentParser> documentParsers;
private static final int MAX_QUESTIONS_PER_INTERVIEW = 10;
@PostConstruct
public void init() {
this.documentParsers = documentParserList.stream()
.collect(Collectors.toMap(DocumentParser::getSupportedType, Function.identity()));
}
/**
* 开始新的面试会话
*/
@Transactional(rollbackFor = Exception.class)
public InterviewResponse startInterview(MultipartFile resume, InterviewStartRequest request) throws IOException {
log.info("开始新面试会话,候选人: {}, AI模型: qwen-max", request.getCandidateName());
// 1. 解析简历
String resumeContent = parseResume(resume);
// 2. 创建会话 并发送AI请求 让其从题库中智能抽题
String sessionId = UUID.randomUUID().toString();
List<Question> selectedQuestions = selectQuestionsByAi(resumeContent, sessionId);
if (selectedQuestions.isEmpty()) {
throw new IllegalStateException("AI未能成功选取题目请检查AI服务或题库。");
}
// 生成面试问题进度数据
if (CollectionUtil.isNotEmpty(selectedQuestions)) {
for (Question question : selectedQuestions) {
InterviewQuestionProgress progress =
new InterviewQuestionProgress()
.setSessionId(sessionId)
.setQuestionId(question.getId())
.setQuestionContent(question.getContent())
.setStatus(InterviewQuestionProgress.Status.DEFAULT.name())
.setTotalQuestions(selectedQuestions.size())
.setScore(BigDecimal.ZERO)
.setAiModel(QWEN_MAX)
.setCandidateName(request.getCandidateName());
questionProgressMapper.insert(progress);
}
}
// 3. 保存AI选择的题目ID列表
List<Long> selectedQuestionIds = selectedQuestions.stream().map(Question::getId).collect(Collectors.toList());
String selectedQuestionIdsJson = objectMapper.writeValueAsString(selectedQuestionIds);
InterviewSession session = createSession(sessionId, request, resumeContent, selectedQuestionIdsJson);
session.setTotalQuestions(selectedQuestions.size()); // 更新会话中的总问题数
sessionMapper.updateById(session); // 更新数据库
// 4. 生成第一个问题
Question firstQuestion = selectedQuestions.get(0);
String firstQuestionContent = generateFirstQuestion(session, firstQuestion, sessionId);
// 激活问题
questionProgressMapper.update(
new LambdaUpdateWrapper<InterviewQuestionProgress>()
.set(InterviewQuestionProgress::getStatus, InterviewQuestionProgress.Status.ACTIVE.name())
.eq(InterviewQuestionProgress::getQuestionId, firstQuestion.getId())
.eq(InterviewQuestionProgress::getSessionId, sessionId)
);
// 5. 保存消息记录
saveMessage(sessionId, InterviewMessage.MessageType.QUESTION.name(),
InterviewMessage.Sender.AI.name(), firstQuestionContent, firstQuestion.getId(), 1);
// 6. 返回响应
return new InterviewResponse()
.setSessionId(sessionId)
.setMessage(firstQuestionContent)
.setMessageType(InterviewMessage.MessageType.QUESTION.name())
.setSender(InterviewMessage.Sender.AI.name())
.setCurrentQuestionIndex(1)
.setCurrentQuestionId(firstQuestion.getId())
.setTotalQuestions(selectedQuestions.size())
.setStatus(InterviewSession.Status.ACTIVE.name());
}
/**
* 处理用户回答并生成下一个问题
*/
@Transactional(rollbackFor = Exception.class)
public InterviewResponse continueInterview(ChatRequest request) {
log.info("继续面试会话: {}", request.getSessionId());
InterviewSession session = sessionMapper.selectBySessionId(request.getSessionId());
if (session == null) {
throw new IllegalArgumentException("会话不存在: " + request.getSessionId());
}
if (!InterviewSession.Status.ACTIVE.name().equals(session.getStatus())) {
throw new IllegalStateException("会话已结束");
}
// 1. 保存用户回答
int nextOrder = messageMapper.selectMaxOrderBySessionId(request.getSessionId()) + 1;
saveMessage(request.getSessionId(), InterviewMessage.MessageType.ANSWER.name(),
InterviewMessage.Sender.USER.name(), request.getUserAnswer(), null, nextOrder);
// 检查是否结束面试
InterviewQuestionProgress progress = questionProgressMapper.selectOne(
new LambdaQueryWrapper<InterviewQuestionProgress>()
.eq(InterviewQuestionProgress::getSessionId, request.getSessionId())
.orderByDesc(InterviewQuestionProgress::getCreatedTime)
.last("limit 1")
);
if (Objects.nonNull(progress) && Objects.equals(progress.getQuestionId(), request.getCurrentQuestionId())) {
}
// 2. 评估回答
Long currentQuestionId = evaluateAnswer(session, request.getUserAnswer());
// 比对返回的id是否与当前id一致
if (currentQuestionId.equals(0L)) {
return finishInterview(session);
}
InterviewQuestionProgress nextQuestionProgress = questionProgressMapper.selectOne(
new LambdaQueryWrapper<InterviewQuestionProgress>()
.eq(InterviewQuestionProgress::getSessionId, request.getSessionId())
.eq(InterviewQuestionProgress::getQuestionId, currentQuestionId)
.orderByDesc(InterviewQuestionProgress::getCreatedTime)
.last("limit 1")
);
// 将ai返回的内容拼装返回给页面
// 查询数据
InterviewQuestionProgress currentQuestionData = questionProgressMapper.selectOne(
new LambdaQueryWrapper<InterviewQuestionProgress>()
.eq(InterviewQuestionProgress::getSessionId, request.getSessionId())
.eq(InterviewQuestionProgress::getQuestionId, request.getCurrentQuestionId())
.orderByDesc(InterviewQuestionProgress::getCreatedTime)
.last("limit 1")
);
StringBuilder sb = new StringBuilder();
if (Objects.nonNull(currentQuestionData)) {
if (StringUtils.isNotBlank(currentQuestionData.getFeedback())) {
sb.append(currentQuestionData.getFeedback()).append("\n");
}
if (StringUtils.isNotBlank(currentQuestionData.getSuggestions())) {
sb.append(currentQuestionData.getSuggestions()).append("\n");
}
if (StringUtils.isNotBlank(currentQuestionData.getAiAnswer())) {
sb.append(currentQuestionData.getAiAnswer()).append("\n");
}
}
if (!currentQuestionId.equals(request.getCurrentQuestionId())) {
// 5. 生成并保存AI的提问消息
String nextQuestionContent = String.format("好的,下一个问题是:%s", nextQuestionProgress.getQuestionContent());
sb.append(nextQuestionContent);
int messageOrder = messageMapper.selectMaxOrderBySessionId(session.getSessionId()) + 1;
saveMessage(session.getSessionId(), InterviewMessage.MessageType.QUESTION.name(),
InterviewMessage.Sender.AI.name(), nextQuestionContent, currentQuestionId, messageOrder);
}
// 6. 返回响应
return new InterviewResponse()
.setSessionId(session.getSessionId())
.setMessage(sb.toString())
.setMessageType(InterviewMessage.MessageType.QUESTION.name())
.setSender(InterviewMessage.Sender.AI.name())
.setCurrentQuestionIndex(session.getCurrentQuestionIndex())
.setTotalQuestions(session.getTotalQuestions())
.setCurrentQuestionId(currentQuestionId)
.setStatus(InterviewSession.Status.ACTIVE.name());
}
/**
* 导入题库使用AI自动分类
*/
/**
* 获取会话历史
*/
public SessionHistoryResponse getSessionHistory(String sessionId) {
InterviewSession session = sessionMapper.selectBySessionId(sessionId);
if (session == null) {
throw new IllegalArgumentException("会话不存在: " + sessionId);
}
List<InterviewMessage> messages = messageMapper.selectBySessionIdOrderByOrder(sessionId);
List<SessionHistoryResponse.MessageDto> messageDtos = messages.stream()
.map(msg -> new SessionHistoryResponse.MessageDto()
.setMessageType(msg.getMessageType())
.setSender(msg.getSender())
.setContent(msg.getContent())
.setMessageOrder(msg.getMessageOrder())
.setCreatedTime(msg.getCreatedTime()))
.collect(Collectors.toList());
return new SessionHistoryResponse()
.setSessionId(sessionId)
.setCandidateName(session.getCandidateName())
.setAiModel(session.getAiModel())
.setStatus(session.getStatus())
.setTotalQuestions(session.getTotalQuestions())
.setCurrentQuestionIndex(session.getCurrentQuestionIndex())
.setCreatedTime(session.getCreatedTime())
.setMessages(messageDtos);
}
private String parseResume(MultipartFile resume) throws IOException {
String fileExtension = getFileExtension(resume.getOriginalFilename());
DocumentParser parser = documentParsers.get(fileExtension);
if (parser == null) {
throw new IllegalArgumentException("不支持的简历文件类型: " + fileExtension);
}
return parser.parse(resume.getInputStream());
}
private List<Question> selectQuestionsByAi(String resumeContent, String sessionId) throws JsonProcessingException {
// 1. 获取全部题库
List<Question> allQuestions = questionMapper.selectList(null);
String questionBankJson = objectMapper.writeValueAsString(allQuestions);
// 2. 构建发送给AI的提示
String prompt = String.format("""
你是一位专业的面试官。请根据以下候选人的简历内容,从提供的题库中,精心挑选出 %d 道最相关的题目进行面试。
要求:
1. 题目必须严格从【题库JSON】中选择。
2. 挑选的题目应根据候选人的简历内容来抽取。
3. 返回一个只包含所选题目ID的JSON数组格式为{"question_ids": [1, 5, 23, ...]}。
4. 不要返回任何多余的代码包括markdown形式的代码我只需要JSON对象请严格按照api接口形式返回
5. 不要返回任何额外的解释或文字只返回JSON对象。
6. 严格按照前后端分离的接口形式返回JSON数据给我不要返回"```json```"
7. 请保证返回数据的完整性不要返回不完整的数据否则我的JSON解析会报错
【候选人简历】:
%s
【题库JSON】:
%s
""", MAX_QUESTIONS_PER_INTERVIEW, resumeContent, questionBankJson);
// 3. 调用AI服务
String aiResponse = llmService.chat(prompt);
log.info("AI抽题响应: {}", aiResponse);
// 4. 解析AI返回的题目ID
List<Long> selectedIds = new ArrayList<>();
try {
JsonNode rootNode = objectMapper.readTree(aiResponse);
JsonNode idsNode = rootNode.get("question_ids");
if (idsNode != null && idsNode.isArray()) {
for (JsonNode idNode : idsNode) {
selectedIds.add(idNode.asLong());
}
}
} catch (JsonProcessingException e) {
log.error("解析AI返回的题目ID列表失败", e);
return Collections.emptyList(); // 解析失败则返回空列表
}
if (selectedIds.isEmpty()) {
return Collections.emptyList();
}
// 5. 根据ID从数据库中获取完整的题目信息并保持AI选择的顺序
List<Question> finalQuestions = questionMapper.selectBatchIds(selectedIds);
finalQuestions.sort(Comparator.comparing(q -> selectedIds.indexOf(q.getId()))); // 保持AI返回的顺序
return finalQuestions;
}
private InterviewSession createSession(String sessionId, InterviewStartRequest request,
String resumeContent, String selectedQuestionIdsJson) {
InterviewSession session = new InterviewSession()
.setSessionId(sessionId)
.setCandidateName(request.getCandidateName())
.setResumeContent(resumeContent)
.setSelectedQuestionIds(selectedQuestionIdsJson)
.setAiModel("qwen-max") // Hardcoded to qwen-max
.setStatus(InterviewSession.Status.ACTIVE.name())
.setCurrentQuestionIndex(0);
sessionMapper.insert(session);
return session;
}
private String generateFirstQuestion(InterviewSession session, Question question, String sessionId) {
String prompt = String.format("""
你是一位专业的技术面试官。现在要开始面试,候选人是 %s。
第一个问题是:%s
请以友好但专业的语气提出这个问题,可以适当添加一些引导性的话语。
""", session.getCandidateName(), question.getContent());
return this.llmService.chat(prompt, sessionId);
}
private void saveMessage(String sessionId, String messageType, String sender,
String content, Long questionId, int order) {
InterviewMessage message = new InterviewMessage()
.setSessionId(sessionId)
.setMessageType(messageType)
.setSender(sender)
.setContent(content)
.setQuestionId(questionId)
.setMessageOrder(order);
messageMapper.insert(message);
}
/**
* 评估答案
*
* @param session 会话数据
* @param userAnswer 用户回答
* @return 当前问题id
*/
private Long evaluateAnswer(InterviewSession session, String userAnswer) {
// 根据会话id查询当前会话所有问题
List<InterviewQuestionProgress> interviewQuestionProgresses = questionProgressMapper.selectList(
new LambdaQueryWrapper<InterviewQuestionProgress>()
.eq(InterviewQuestionProgress::getSessionId, session.getSessionId())
.orderByAsc(InterviewQuestionProgress::getCreatedTime)
);
if (CollectionUtil.isEmpty(interviewQuestionProgresses)) {
throw new RuntimeException("当前会话没有任何可询问的问题!");
}
// 1. 获取当前正在回答的问题
InterviewQuestionProgress currentQuestionProgress = null;
for (InterviewQuestionProgress interviewQuestionProgress : interviewQuestionProgresses) {
if (interviewQuestionProgress.getStatus().equals(InterviewQuestionProgress.Status.ACTIVE.name())) {
currentQuestionProgress = interviewQuestionProgress;
break;
}
}
if (Objects.isNull(currentQuestionProgress)) {
throw new RuntimeException("当前没有正在回答的问题");
}
Long currentQuestionId = currentQuestionProgress.getQuestionId();
List<String> questionIds = interviewQuestionProgresses.stream()
.map(data -> {
return data.getQuestionId().toString();
})
.collect(Collectors.toList());
String join = String.join(",", questionIds);
// 2. 构建评估提示
String prompt = String.format("""
你是一位资深的技术面试官。请根据以下问题和候选人的回答,进行一次专业的评估。
要求:
1. 对回答的质量进行打分分数范围为1-5分。
2. 给出简洁、专业的评语。
3. 提出具体的改进建议以及你认为应该回答的答案。
4. 以严格的JSON格式返回不要包含任何额外的解释文字。格式如下
{
"score": 4.5,
"feedback": "回答基本正确,但可以更深入...",
"suggestions": "可以补充关于XXX方面的知识点...",
"answer": "关于当前问题您应该这样回答xxx",
"currentQuestionId": xxx
}
5. 不要返回任何多余字符请严格按照api接口格式的JSON数据进行返回不要包含"```json```"
6. 如果你认为面试人对当前问题回答不完美可以继续对当前问题进行补充提问但不要修改currentQuestionId
7. 如果你认为面试人对当前问题回答已经比较好了或者面试人回答不上来了请你根据questionIds数据顺序选择下一个问题并修改currentQuestionId进行返回
8. 如果所有问题都已回答完成请将currentQuestionId设置为0
{
"questionIds": %s,
"currentQuestionId": %s
}
【面试问题】:
%s
【候选人回答】:
%s
""", join, currentQuestionProgress.getQuestionId(), currentQuestionProgress.getQuestionContent(), userAnswer);
// 3. 调用AI进行评估
String aiResponse = llmService.chat(prompt, session.getSessionId());
log.info("AI评估响应: {}", aiResponse);
// 4. 解析AI响应并存储评估结果
try {
JsonNode rootNode = objectMapper.readTree(aiResponse);
InterviewEvaluation evaluation = new InterviewEvaluation()
.setSessionId(session.getSessionId())
.setQuestionId(currentQuestionId)
.setUserAnswer(userAnswer)
.setScore(new java.math.BigDecimal(rootNode.get("score").asText()))
.setAiFeedback(rootNode.get("feedback").asText())
.setEvaluationCriteria(rootNode.get("suggestions").asText()); // 暂时复用这个字段存建议
JsonNode currentQuestionId1 = rootNode.get("currentQuestionId");
JsonNode aiAnswerNode = rootNode.get("answer");
if (Objects.nonNull(currentQuestionId1)) {
String text = currentQuestionId1.asText();
if (StringUtils.isNoneBlank(text)) {
currentQuestionProgress
.setScore(new BigDecimal(rootNode.get("score").asText()))
.setSuggestions(rootNode.get("suggestions").asText())
.setFeedback(rootNode.get("feedback").asText())
.setAiAnswer(Objects.nonNull(aiAnswerNode) ? aiAnswerNode.asText() : null)
.setUserAnswer(userAnswer)
;
if (!StrUtil.equals(text, currentQuestionProgress.getQuestionId().toString())) {
currentQuestionProgress.setStatus(InterviewQuestionProgress.Status.COMPLETED.name());
questionProgressMapper.updateById(currentQuestionProgress);
questionProgressMapper.update(
new LambdaUpdateWrapper<InterviewQuestionProgress>()
.set(InterviewQuestionProgress::getStatus, InterviewQuestionProgress.Status.ACTIVE.name())
.eq(InterviewQuestionProgress::getSessionId, session.getSessionId())
.eq(InterviewQuestionProgress::getQuestionId, Long.valueOf(text))
);
} else if (text.equals("0")) {
currentQuestionProgress.setStatus(InterviewQuestionProgress.Status.COMPLETED.name());
questionProgressMapper.updateById(currentQuestionProgress);
}
currentQuestionId = Long.valueOf(text);
}
}
evaluationMapper.insert(evaluation);
log.info("成功存储对问题ID {} 的评估结果", currentQuestionId);
return currentQuestionId;
} catch (Exception e) {
log.error("解析或存储AI评估结果失败", e);
throw new RuntimeException("解析或存储AI评估结果失败");
}
}
private InterviewResponse finishInterview(InterviewSession session) {
// 1. 获取本次面试的所有评估数据
List<InterviewEvaluation> evaluations = evaluationMapper.selectBySessionId(session.getSessionId());
// 2. 构建生成最终报告的提示
String prompt = buildFinalReportPrompt(session, evaluations);
// 3. 调用AI生成报告
String finalReportJson = llmService.chat(prompt, session.getSessionId());
log.info("AI生成的最终面试报告: {}", finalReportJson);
// 4. 更新会话状态和最终报告
session.setStatus(InterviewSession.Status.COMPLETED.name());
session.setFinalReport(finalReportJson);
sessionMapper.updateById(session);
// 5. 返回结束信息
return new InterviewResponse()
.setSessionId(session.getSessionId())
.setMessage("面试已结束感谢您的参与AI正在生成您的面试报告请稍后在面试历史中查看。")
.setMessageType(InterviewMessage.MessageType.SYSTEM.name())
.setSender(InterviewMessage.Sender.SYSTEM.name())
.setCurrentQuestionId(null)
.setStatus(InterviewSession.Status.COMPLETED.name());
}
private String buildFinalReportPrompt(InterviewSession session, List<InterviewEvaluation> evaluations) {
StringBuilder historyBuilder = new StringBuilder();
for (InterviewEvaluation eval : evaluations) {
Question q = questionMapper.selectById(eval.getQuestionId());
historyBuilder.append(String.format("\n【问题】: %s\n【回答】: %s\n【AI单题反馈】: %s\n【AI单题建议】: %s\n【AI单题评分】: %s/5.0\n",
q.getContent(), eval.getUserAnswer(), eval.getAiFeedback(), eval.getEvaluationCriteria(), eval.getScore()));
}
return String.format("""
你是一位资深的HR和技术总监。请根据以下候选人的简历、完整的面试问答历史和AI对每一题的初步评估给出一份全面、专业、有深度的最终面试报告。
要求:
1. **综合评价**: 对候选人的整体表现给出一个总结性的评语,点出其核心亮点和主要不足。
2. **技术能力评估**: 分点阐述候选人在不同技术领域如Java基础, Spring, 数据库等)的掌握程度。
3. **改进建议**: 给出3-5条具体的、可操作的学习和改进建议。
4. **综合得分**: 给出一个1-100分的最终综合得分。
5. **录用建议**: 给出明确的录用建议(如:强烈推荐、推荐、待考虑、不推荐)。
6. 以严格的JSON格式返回不要包含任何额外的解释文字。格式如下
{
"overallScore": 85,
"overallFeedback": "候选人Java基础扎实但在高并发场景下的经验有所欠缺...",
"technicalAssessment": {
"Java基础": "掌握良好,对集合框架理解深入。",
"Spring框架": "熟悉基本使用,但对底层原理理解不足。",
"数据库": "能够编写常规SQL但在索引优化方面知识欠缺。"
},
"suggestions": [
"深入学习Spring AOP和事务管理的实现原理。",
"系统学习MySQL索引优化和查询性能分析。",
"通过实际项目积累高并发处理经验。"
],
"hiringRecommendation": "推荐"
}
【候选人简历摘要】:
%s
【面试问答与评估历史】:
%s
""", session.getResumeContent(), historyBuilder.toString());
}
private InterviewResponse generateNextQuestion(InterviewSession session) {
try {
// 1. 解析出AI选择的题目ID列表
List<Long> selectedQuestionIds = objectMapper.readValue(session.getSelectedQuestionIds(), new com.fasterxml.jackson.core.type.TypeReference<List<Long>>() {
});
// 2. 获取下一个问题的索引
int nextQuestionIndex = session.getCurrentQuestionIndex(); // 数据库中存的是已回答问题的数量
if (nextQuestionIndex >= selectedQuestionIds.size()) {
return finishInterview(session); // 如果没有更多问题,则结束面试
}
// 3. 获取下一个问题的ID并从数据库查询
Long nextQuestionId = selectedQuestionIds.get(nextQuestionIndex);
Question nextQuestion = questionMapper.selectById(nextQuestionId);
if (nextQuestion == null) {
log.error("无法找到ID为 {} 的问题,跳过此问题。", nextQuestionId);
// 更新会话状态并尝试下一个问题
session.setCurrentQuestionIndex(nextQuestionIndex + 1);
sessionMapper.updateById(session);
return generateNextQuestion(session); // 递归调用以获取再下一个问题
}
// 4. 更新会话状态(当前问题索引+1
session.setCurrentQuestionIndex(nextQuestionIndex + 1);
sessionMapper.updateById(session);
// 5. 生成并保存AI的提问消息
String questionContent = String.format("好的,下一个问题是:%s", nextQuestion.getContent());
int messageOrder = messageMapper.selectMaxOrderBySessionId(session.getSessionId()) + 1;
saveMessage(session.getSessionId(), InterviewMessage.MessageType.QUESTION.name(),
InterviewMessage.Sender.AI.name(), questionContent, nextQuestion.getId(), messageOrder);
// 6. 返回响应
return new InterviewResponse()
.setSessionId(session.getSessionId())
.setMessage(questionContent)
.setMessageType(InterviewMessage.MessageType.QUESTION.name())
.setSender(InterviewMessage.Sender.AI.name())
.setCurrentQuestionIndex(session.getCurrentQuestionIndex())
.setTotalQuestions(session.getTotalQuestions())
.setStatus(InterviewSession.Status.ACTIVE.name());
} catch (JsonProcessingException e) {
log.error("解析会话中的题目ID列表失败", e);
return finishInterview(session); // 解析失败则直接结束面试
}
}
/**
* 获取所有面试会话列表
*/
public List<InterviewSession> getInterviewSessions() {
log.info("Fetching all interview sessions");
return sessionMapper.selectList(null); // 实际中可能需要分页
}
/**
* 获取详细的面试复盘报告
*/
public InterviewReportResponse getInterviewReport(String sessionId) {
log.info("Fetching interview report for session id: {}", sessionId);
InterviewSession session = sessionMapper.selectBySessionId(sessionId);
if (session == null) {
throw new IllegalArgumentException("找不到ID为 " + sessionId + " 的面试会话。");
}
List<InterviewEvaluation> evaluations = evaluationMapper.selectBySessionId(sessionId);
List<InterviewReportResponse.QuestionDetail> questionDetails = evaluations.stream().map(eval -> {
Question question = questionMapper.selectById(eval.getQuestionId());
InterviewReportResponse.QuestionDetail detail = new InterviewReportResponse.QuestionDetail();
detail.setQuestionId(eval.getQuestionId());
detail.setQuestionContent(question != null ? question.getContent() : "题目已不存在");
detail.setUserAnswer(eval.getUserAnswer());
detail.setAiFeedback(eval.getAiFeedback());
detail.setSuggestions(eval.getEvaluationCriteria());
detail.setScore(eval.getScore());
return detail;
}).collect(Collectors.toList());
InterviewReportResponse report = new InterviewReportResponse();
report.setSessionDetails(session);
report.setQuestionDetails(questionDetails);
List<InterviewMessage> interviewMessages = messageMapper.selectList(
new LambdaQueryWrapper<InterviewMessage>()
.eq(InterviewMessage::getSessionId, sessionId)
);
// 获取当前面试的 问题
InterviewQuestionProgress progress = questionProgressMapper.selectOne(
new LambdaQueryWrapper<InterviewQuestionProgress>()
.eq(InterviewQuestionProgress::getSessionId, sessionId)
.eq(InterviewQuestionProgress::getStatus, InterviewQuestionProgress.Status.ACTIVE.name())
.last("LIMIT 1")
);
if (Objects.nonNull(progress)) {
report.setCurrentQuestionId(progress.getQuestionId());
}
report.setMessages(interviewMessages);
return report;
}
private String getFileExtension(String fileName) {
if (fileName == null || fileName.lastIndexOf('.') == -1) {
return "";
}
return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
}
}
package com.qingqiu.interview.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.qingqiu.interview.dto.InterviewReportResponse;
import com.qingqiu.interview.dto.InterviewStartRequest;
import com.qingqiu.interview.dto.SubmitAnswerDTO;
import com.qingqiu.interview.entity.InterviewMessage;
import com.qingqiu.interview.entity.InterviewQuestionProgress;
import com.qingqiu.interview.entity.InterviewSession;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
/**
* <h1></h1>
*
* @author qingqiu
* @date 2025/9/19 16:05
*/
public interface InterviewService extends IService<InterviewSession> {
/**
* 开始一场新的面试
*
* @param file 简历文件
* @param dto 开始面试的请求参数
* @return 创建的面试会话
*/
InterviewSession startInterview(MultipartFile file, InterviewStartRequest dto) throws IOException;
/**
* 获取下一个问题
*
* @param sessionId 会话ID
* @return 下一个问题 或 null如果没有更多问题
*/
InterviewMessage getNextQuestion(String sessionId, Long progressId);
/**
* 提交答案并获取AI评估
*
* @param submitAnswerDTO 提交答案的请求参数
* @return 对当前问题的评估和反馈
*/
InterviewQuestionProgress submitAnswer(SubmitAnswerDTO submitAnswerDTO);
/**
* 结束面试并生成最终报告
*
* @param sessionId 会话ID
* @return 包含最终报告的面试会话信息
*/
InterviewSession endInterview(String sessionId);
/**
* 获取面试报告
*
* @param sessionId
* @return
*/
InterviewReportResponse getInterviewReport(String sessionId);
/**
* 读取pdf文件数据
*
* @param file 文件
* @return 文件内容
*/
String readPdfFile(MultipartFile file) throws IOException;
}

Some files were not shown because too many files have changed in this diff Show More