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> <artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- knife4j接口文档--> <!-- knife4j接口文档-->
<dependency> <dependency>
<groupId>com.github.xiaoymin</groupId> <groupId>com.github.xiaoymin</groupId>
@@ -87,6 +88,13 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId> <artifactId>spring-boot-starter-json</artifactId>
</dependency> </dependency>
<!--Ai-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-deepseek</artifactId>
</dependency>
<!-- mybatis-plus--> <!-- mybatis-plus-->
<dependency> <dependency>
<groupId>com.baomidou</groupId> <groupId>com.baomidou</groupId>
@@ -131,6 +139,18 @@
</dependency> </dependency>
</dependencies> </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> <build>
<plugins> <plugins>
<plugin> <plugin>
@@ -169,4 +189,15 @@
</plugins> </plugins>
</build> </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> </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.ErrorCode;
import com.zds.boss.exception.ThrowUtils; import com.zds.boss.exception.ThrowUtils;
import com.zds.boss.model.dto.resume.ResumeAddRequest; 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.ResumeQueryRequest;
import com.zds.boss.model.dto.resume.ResumeUpdateRequest; import com.zds.boss.model.dto.resume.ResumeUpdateRequest;
import com.zds.boss.model.entity.Resume; import com.zds.boss.model.entity.Resume;
import com.zds.boss.model.entity.User; import com.zds.boss.model.entity.User;
import com.zds.boss.model.enums.UserRoleEnum; 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.model.vo.ResumeVO;
import com.zds.boss.service.AiService;
import com.zds.boss.service.CosService; import com.zds.boss.service.CosService;
import com.zds.boss.service.FileService; import com.zds.boss.service.FileService;
import com.zds.boss.service.ResumeAddressService; import com.zds.boss.service.ResumeAddressService;
@@ -51,6 +54,9 @@ public class ResumeController {
@Resource @Resource
private ResumeAddressService resumeAddressService; private ResumeAddressService resumeAddressService;
@Resource
private AiService aiService;
@Value("${file.max-size-mb:10}") @Value("${file.max-size-mb:10}")
private long maxSizeMb; private long maxSizeMb;
@@ -303,4 +309,37 @@ public class ResumeController {
return ResultUtils.success(result); 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: application:
name: BOSS name: BOSS
ai:
deepseek:
api-key:
base-url: https://api.deepseek.com
chat:
options:
model: deepseek-chat
datasource: datasource:
driver-class-name: com.mysql.cj.jdbc.Driver 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 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 host: 127.0.0.1
port: 6379 port: 6379
database: 0 database: 0
password: "" # ????????? password: ""
timeout: 5s timeout: 5s
lettuce: lettuce:
pool: pool:

View File

@@ -96,3 +96,32 @@ export async function deleteResumeFile(
...(options || {}), ...(options || {}),
}) })
} }
/** AI优化简历请求 */
export interface ResumeAiOptimizeRequest {
resumeTitle?: string
summary?: string
content?: string
}
/** AI优化简历响应 */
export interface ResumeAiOptimizeVO {
resumeTitle?: string
summary?: string
content?: string
}
/** AI优化简历 POST /resume/ai/optimize */
export async function aiOptimizeResume(
body: ResumeAiOptimizeRequest,
options?: { [key: string]: any }
) {
return request<API.BaseResponse<ResumeAiOptimizeVO>>('/resume/ai/optimize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
})
}

View File

@@ -35,26 +35,6 @@
</a-table> </a-table>
</a-card> </a-card>
<!-- <a-card title="我的简历PDF" :bordered="false" style="margin-top: 16px">-->
<!-- <div v-if="addressLoading">-->
<!-- <a-spin />-->
<!-- </div>-->
<!-- <div v-else>-->
<!-- <div v-if="myAddress?.address">-->
<!-- <a-space>-->
<!-- <a :href="myAddress.address" target="_blank">查看PDF</a>-->
<!-- <a-popconfirm title="确定删除该PDF吗" @confirm="deleteAddress">-->
<!-- <a-button danger>删除</a-button>-->
<!-- </a-popconfirm>-->
<!-- </a-space>-->
<!-- </div>-->
<!-- <div v-else>-->
<!-- <a-upload :before-upload="beforeUpload" :show-upload-list="false" :custom-request="(opt:any)=>doUpload(opt.file)">-->
<!-- <a-button type="primary">上传PDF</a-button>-->
<!-- </a-upload>-->
<!-- </div>-->
<!-- </div>-->
<!-- </a-card>-->
</div> </div>
</template> </template>

View File

@@ -69,6 +69,37 @@
<a-switch v-model:checked="isDefaultChecked" /> <a-switch v-model:checked="isDefaultChecked" />
</a-form-item> </a-form-item>
<!-- AI优化区域 -->
<a-form-item label="AI优化" :wrapper-col="{ span: 16 }">
<a-space direction="vertical" style="width: 100%">
<a-button
type="default"
:loading="aiOptimizing"
@click="handleAiOptimize"
:disabled="aiOptimizing"
class="ai-optimize-btn"
>
<template #icon>
<RobotOutlined />
</template>
{{ aiOptimizing ? 'AI优化中...' : 'DeepSeek AI 智能优化' }}
</a-button>
<a-progress
v-if="aiOptimizing"
:percent="Math.round(aiProgress)"
:status="aiProgress >= 100 ? 'success' : 'active'"
:stroke-color="{
'0%': '#108ee9',
'100%': '#87d068',
}"
size="small"
/>
<div v-if="!aiOptimizing" style="color: #999; font-size: 12px">
使用 DeepSeek AI 智能优化您的简历内容让简历更专业更有吸引力
</div>
</a-space>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 4, span: 16 }"> <a-form-item :wrapper-col="{ offset: 4, span: 16 }">
<a-button type="primary" html-type="submit" :loading="loading">保存</a-button> <a-button type="primary" html-type="submit" :loading="loading">保存</a-button>
<a-button style="margin-left: 10px" @click="router.back()">取消</a-button> <a-button style="margin-left: 10px" @click="router.back()">取消</a-button>
@@ -82,9 +113,9 @@
import NavBar from '@/components/NavBar.vue'; import NavBar from '@/components/NavBar.vue';
import { reactive, ref, onMounted, computed } from 'vue'; import { reactive, ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { addResume, updateResume, getResumeVoById, deleteResumeFile } from '@/api/api/resumeController'; import { addResume, updateResume, getResumeVoById, deleteResumeFile, aiOptimizeResume } from '@/api/api/resumeController';
import { message, Upload } from 'ant-design-vue'; import { message, Upload } from 'ant-design-vue';
import { UploadOutlined, FilePdfOutlined } from '@ant-design/icons-vue'; import { UploadOutlined, FilePdfOutlined, RobotOutlined } from '@ant-design/icons-vue';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -95,6 +126,11 @@ const uploading = ref(false);
const uploadProgress = ref(0); const uploadProgress = ref(0);
const uploadFileName = ref(''); const uploadFileName = ref('');
// AI优化相关状态
const aiOptimizing = ref(false);
const aiProgress = ref(0);
let aiProgressTimer: ReturnType<typeof setInterval> | null = null;
const formState = reactive({ const formState = reactive({
resumeTitle: '', resumeTitle: '',
summary: '', summary: '',
@@ -229,6 +265,86 @@ const clearAttachment = async () => {
uploadFileName.value = ''; uploadFileName.value = '';
}; };
// 开始伪进度条
const startFakeProgress = () => {
aiProgress.value = 0;
aiProgressTimer = setInterval(() => {
// 伪进度条逻辑快速增长到30%然后慢慢增长到90%
if (aiProgress.value < 30) {
aiProgress.value += Math.random() * 8 + 2; // 2-10%
} else if (aiProgress.value < 60) {
aiProgress.value += Math.random() * 4 + 1; // 1-5%
} else if (aiProgress.value < 90) {
aiProgress.value += Math.random() * 2 + 0.5; // 0.5-2.5%
}
// 最多到90%等待AI返回后才变成100%
if (aiProgress.value > 90) {
aiProgress.value = 90;
}
}, 300);
};
// 停止伪进度条
const stopFakeProgress = (success: boolean) => {
if (aiProgressTimer) {
clearInterval(aiProgressTimer);
aiProgressTimer = null;
}
if (success) {
aiProgress.value = 100;
// 100%后延迟重置
setTimeout(() => {
aiProgress.value = 0;
aiOptimizing.value = false;
}, 500);
} else {
aiProgress.value = 0;
aiOptimizing.value = false;
}
};
// AI优化简历
const handleAiOptimize = async () => {
// 检查是否有内容可优化
if (!formState.resumeTitle?.trim() && !formState.summary?.trim() && !formState.content?.trim()) {
message.warning('请至少填写一项内容后再进行AI优化');
return;
}
aiOptimizing.value = true;
startFakeProgress();
try {
const res = await aiOptimizeResume({
resumeTitle: formState.resumeTitle,
summary: formState.summary,
content: formState.content,
});
if (res.code === 0 && res.data) {
// 填充AI优化后的内容
if (res.data.resumeTitle) {
formState.resumeTitle = res.data.resumeTitle;
}
if (res.data.summary) {
formState.summary = res.data.summary;
}
if (res.data.content) {
formState.content = res.data.content;
}
stopFakeProgress(true);
message.success('AI优化完成');
} else {
stopFakeProgress(false);
message.error(res.message || 'AI优化失败');
}
} catch (error: any) {
console.error('AI优化失败:', error);
stopFakeProgress(false);
message.error(error.message || 'AI优化失败请稍后重试');
}
};
const onFinish = async (values: any) => { const onFinish = async (values: any) => {
loading.value = true; loading.value = true;
try { try {
@@ -266,4 +382,25 @@ onMounted(() => {
background-color: #f0f2f5; background-color: #f0f2f5;
min-height: calc(100vh - 64px); min-height: calc(100vh - 64px);
} }
.ai-optimize-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
font-weight: 500;
transition: all 0.3s ease;
}
.ai-optimize-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
color: white;
}
.ai-optimize-btn:disabled {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
opacity: 0.7;
color: white;
}
</style> </style>