feat(ai): 集成DeepSeek AI优化简历功能

- 配置DeepSeek AI相关依赖和配置项
- 创建ChatController提供AI聊天测试接口
- 实现AiService和AiServiceImpl提供AI优化服务
- 添加ResumeAiOptimizeRequest和ResumeAiOptimizeVO数据传输对象
- 在ResumeController中添加AI优化简历的API端点
- 前端ResumeEdit页面集成AI优化按钮和进度显示
- 实现前端调用AI优化接口并更新表单内容
- 添加AI优化相关的样式和交互效果
This commit is contained in:
2026-01-15 00:03:43 +08:00
parent 44a379bd0e
commit 6cc2725923
12 changed files with 544 additions and 23 deletions

View File

@@ -48,6 +48,7 @@
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- knife4j接口文档-->
<dependency>
<groupId>com.github.xiaoymin</groupId>
@@ -87,6 +88,13 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
<!--Ai-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-deepseek</artifactId>
</dependency>
<!-- mybatis-plus-->
<dependency>
<groupId>com.baomidou</groupId>
@@ -131,6 +139,18 @@
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
@@ -169,4 +189,15 @@
</plugins>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
</project>

View File

@@ -0,0 +1,46 @@
package com.zds.boss.controller;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/chat")
public class ChatController {
private final ChatClient chatClient;
private final ChatModel chatModel;
public ChatController(ChatClient.Builder chatClientBuilder, ChatModel chatModel) {
this.chatClient = chatClientBuilder.build();
this.chatModel = chatModel;
}
@GetMapping("/test")
public String completion(@RequestParam String message) {
try {
return chatClient.prompt()
.user(message)
.call()
.content();
} catch (Exception e) {
return "Error: " + e.getMessage();
}
}
@GetMapping("/model")
public String model(@RequestParam String message) {
try {
Prompt prompt = new Prompt(message);
ChatResponse response = chatModel.call(prompt);
return response.getResult().getOutput().getText();
} catch (Exception e) {
return "Error: " + e.getMessage();
}
}
}

View File

@@ -8,12 +8,15 @@ import com.zds.boss.exception.BusinessException;
import com.zds.boss.exception.ErrorCode;
import com.zds.boss.exception.ThrowUtils;
import com.zds.boss.model.dto.resume.ResumeAddRequest;
import com.zds.boss.model.dto.resume.ResumeAiOptimizeRequest;
import com.zds.boss.model.dto.resume.ResumeQueryRequest;
import com.zds.boss.model.dto.resume.ResumeUpdateRequest;
import com.zds.boss.model.entity.Resume;
import com.zds.boss.model.entity.User;
import com.zds.boss.model.enums.UserRoleEnum;
import com.zds.boss.model.vo.ResumeAiOptimizeVO;
import com.zds.boss.model.vo.ResumeVO;
import com.zds.boss.service.AiService;
import com.zds.boss.service.CosService;
import com.zds.boss.service.FileService;
import com.zds.boss.service.ResumeAddressService;
@@ -51,6 +54,9 @@ public class ResumeController {
@Resource
private ResumeAddressService resumeAddressService;
@Resource
private AiService aiService;
@Value("${file.max-size-mb:10}")
private long maxSizeMb;
@@ -303,4 +309,37 @@ public class ResumeController {
return ResultUtils.success(result);
}
/**
* AI优化简历
*
* @param request AI优化请求
* @param httpRequest HTTP请求
* @return 优化后的简历内容
*/
@PostMapping("/ai/optimize")
public BaseResponse<ResumeAiOptimizeVO> aiOptimizeResume(
@RequestBody ResumeAiOptimizeRequest request,
HttpServletRequest httpRequest) {
if (request == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "请求参数不能为空");
}
// 验证用户登录
userService.getLoginUser(httpRequest);
// 检查是否有内容需要优化
boolean hasContent = (request.getResumeTitle() != null && !request.getResumeTitle().trim().isEmpty())
|| (request.getSummary() != null && !request.getSummary().trim().isEmpty())
|| (request.getContent() != null && !request.getContent().trim().isEmpty());
if (!hasContent) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "请至少填写一项内容后再进行AI优化");
}
// 调用AI服务优化简历
ResumeAiOptimizeVO result = aiService.optimizeResume(request);
return ResultUtils.success(result);
}
}

View File

@@ -0,0 +1,29 @@
package com.zds.boss.model.dto.resume;
import lombok.Data;
import java.io.Serializable;
/**
* AI简历优化请求
*/
@Data
public class ResumeAiOptimizeRequest implements Serializable {
/**
* 简历标题
*/
private String resumeTitle;
/**
* 个人摘要
*/
private String summary;
/**
* 详细内容
*/
private String content;
private static final long serialVersionUID = 1L;
}

View File

@@ -0,0 +1,29 @@
package com.zds.boss.model.vo;
import lombok.Data;
import java.io.Serializable;
/**
* AI简历优化响应
*/
@Data
public class ResumeAiOptimizeVO implements Serializable {
/**
* 优化后的简历标题
*/
private String resumeTitle;
/**
* 优化后的个人摘要
*/
private String summary;
/**
* 优化后的详细内容
*/
private String content;
private static final long serialVersionUID = 1L;
}

View File

@@ -0,0 +1,18 @@
package com.zds.boss.service;
import com.zds.boss.model.dto.resume.ResumeAiOptimizeRequest;
import com.zds.boss.model.vo.ResumeAiOptimizeVO;
/**
* AI服务接口
*/
public interface AiService {
/**
* 使用AI优化简历
*
* @param request 简历优化请求
* @return 优化后的简历内容
*/
ResumeAiOptimizeVO optimizeResume(ResumeAiOptimizeRequest request);
}

View File

@@ -0,0 +1,174 @@
package com.zds.boss.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zds.boss.exception.BusinessException;
import com.zds.boss.exception.ErrorCode;
import com.zds.boss.model.dto.resume.ResumeAiOptimizeRequest;
import com.zds.boss.model.vo.ResumeAiOptimizeVO;
import com.zds.boss.service.AiService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.deepseek.DeepSeekChatModel;
import org.springframework.stereotype.Service;
/**
* AI服务实现类
*/
@Service
@Slf4j
public class AiServiceImpl implements AiService {
@Resource
private DeepSeekChatModel deepSeekChatModel;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* 系统提示词 - 专业简历分析师
*/
private static final String SYSTEM_PROMPT = """
你是一位专业的简历分析师和职业规划顾问拥有10年以上的人力资源和招聘经验。
你的任务是帮助用户优化他们的简历,使其更加专业、吸引人,并能更好地展示他们的技能和经验。
优化原则:
1. 简历标题:简洁有力,突出核心职位和关键技能,格式如"职位名称-姓名""职位名称 | 核心优势"
2. 个人摘要控制在100-200字突出核心竞争力、关键成就和职业目标使用有力的动词开头
3. 详细内容使用STAR法则情境-任务-行动-结果)描述经历,量化成就,突出与目标职位相关的技能
优化要求:
- 语言简洁专业,避免口语化表达
- 突出数据和成果,使用具体数字量化成就
- 使用行业关键词提高ATS简历筛选系统通过率
- 保持内容真实,在原有基础上进行润色和优化
- 如果原内容较少,可以适当扩展,但要基于原有信息合理推断
你必须严格按照以下JSON格式返回结果不要包含任何其他内容
{
"resumeTitle": "优化后的简历标题",
"summary": "优化后的个人摘要",
"content": "优化后的详细内容"
}
""";
@Override
public ResumeAiOptimizeVO optimizeResume(ResumeAiOptimizeRequest request) {
log.info("开始AI简历优化标题: {}", request.getResumeTitle());
// 构建用户消息
String userMessage = String.format("""
请优化以下简历内容:
【简历标题】
%s
【个人摘要】
%s
【详细内容】
%s
请按照JSON格式返回优化后的结果。
""",
nullToEmpty(request.getResumeTitle()),
nullToEmpty(request.getSummary()),
nullToEmpty(request.getContent())
);
try {
// 调用DeepSeek API
ChatClient chatClient = ChatClient.builder(deepSeekChatModel).build();
String response = chatClient.prompt()
.system(SYSTEM_PROMPT)
.user(userMessage)
.call()
.content();
log.info("AI返回原始响应: {}", response);
// 解析JSON响应
ResumeAiOptimizeVO result = parseResponse(response);
log.info("AI简历优化完成");
return result;
} catch (Exception e) {
log.error("AI简历优化失败: {}", e.getMessage(), e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "AI优化失败请稍后重试: " + e.getMessage());
}
}
/**
* 解析AI响应的JSON
*/
private ResumeAiOptimizeVO parseResponse(String response) {
try {
// 尝试提取JSON部分处理可能的markdown代码块
String jsonStr = extractJson(response);
JsonNode jsonNode = objectMapper.readTree(jsonStr);
ResumeAiOptimizeVO vo = new ResumeAiOptimizeVO();
vo.setResumeTitle(getTextValue(jsonNode, "resumeTitle"));
vo.setSummary(getTextValue(jsonNode, "summary"));
vo.setContent(getTextValue(jsonNode, "content"));
return vo;
} catch (Exception e) {
log.error("解析AI响应失败: {}", e.getMessage());
throw new BusinessException(ErrorCode.OPERATION_ERROR, "AI响应解析失败");
}
}
/**
* 从响应中提取JSON字符串
*/
private String extractJson(String response) {
if (response == null || response.isEmpty()) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "AI响应为空");
}
// 处理markdown代码块
if (response.contains("```json")) {
int start = response.indexOf("```json") + 7;
int end = response.lastIndexOf("```");
if (end > start) {
return response.substring(start, end).trim();
}
}
if (response.contains("```")) {
int start = response.indexOf("```") + 3;
int end = response.lastIndexOf("```");
if (end > start) {
return response.substring(start, end).trim();
}
}
// 尝试找到JSON对象
int start = response.indexOf("{");
int end = response.lastIndexOf("}");
if (start >= 0 && end > start) {
return response.substring(start, end + 1);
}
return response.trim();
}
/**
* 安全获取JSON节点的文本值
*/
private String getTextValue(JsonNode node, String field) {
if (node.has(field) && !node.get(field).isNull()) {
return node.get(field).asText();
}
return "";
}
/**
* 将null转换为空字符串
*/
private String nullToEmpty(String str) {
return str == null ? "" : str;
}
}

View File

@@ -0,0 +1 @@
spring.profiles.active=dev

View File

@@ -19,6 +19,14 @@ spring:
application:
name: BOSS
ai:
deepseek:
api-key:
base-url: https://api.deepseek.com
chat:
options:
model: deepseek-chat
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/boss?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
@@ -34,7 +42,7 @@ spring:
host: 127.0.0.1
port: 6379
database: 0
password: "" # ?????????
password: ""
timeout: 5s
lettuce:
pool: