【Dify-Chatflow】简历优化助手实现+前后端分离式系统集成+Docker容器化部署)

一、介绍

**本期内容通过私有化部署的Dify,创建Chatflow应用,通过应用编排搭建实现一个可以上传简历并进行意见反馈和简历优化的智能助手;****并开发一个基于SpringBoot+Vue的前后端分离式项目;****最终将两者集成在一起,实现完整的用户可使用的项目,**并使用Dockerfile和DockerCompose进行Linux系统(CentOS)的容器化部署,实现在Windows系统下的访问。

基于Dify实现的简历优化助手的完整详细步骤见:

https://blog.csdn.net/2401_84926677/article/details/154994183

本文主要介绍 :

前后端分离式系统的开发与Dify简历优化助手的集成。

二、步骤

1.测试运行

确保dify控制台的简历优化智能体工作流可以正常运行

点击运行进行测试:

正常运行:

2.访问API,获取密钥

点击已发布的简历优化助手,访问API:

可以看到你的基础URL、鉴权格式、右上角的API密钥:

点击右上角的API密钥,复制保存,以供后续鉴权使用:

3.Apifox接口调用测试

a.新建接口,输入基础URL,请求方式改为POST
b.添加Header鉴权,引入API密钥
c.文件上传接口测试(/files/upload)

准备测试材料:

  • 简历文件(比如resume.pdf
  • 确定user标识(例如test-user-001

构造请求:

  • 请求方法:POST
  • 请求地址:http://192.168.150.103/v1/files/upload(你的请求地址)
  • 请求头:Authorization: Bearer {api_key}
  • 请求体:multipart/form-data格式,包含:
    • file:选择本地简历文件
    • user:填写test-user-001

点击上传简历文件:

执行请求,验证响应:

  • 响应体需包含id(文件唯一标识)、name(文件名)等信息,记录id(后续步骤用)

简历是上传到提供该 API 的服务端存储中

  • 仅当前用户可用:接口说明提到 "上传的文件仅供当前终端用户使用",所以只有上传时填写的user标识对应的用户,才能在发送消息接口中引用这个文件。
  • 临时 / 应用内存储:这类文件通常是存储在服务端的临时或应用专属存储中(不会公开到外部),主要用于配合/chat-messages接口完成对话中的多模态(文件 + 文本)交互。
d.发送消息接口测试(/chat-messages)

准备测试材料:

  • 步骤 1 中获取的文件id
  • 优化需求的query(例如 "帮我优化这份简历的求职意向和项目描述")
  • 相同的user标识(test-user-001

构造请求:

  • 请求方法:POST
  • 请求地址:http://192.168.150.103/v1/chat-messages
  • 请求头:
    • Authorization: Bearer {api_key}
    • Content-Type: application/json
  • 请求体(JSON 格式):
bash 复制代码
{
  "inputs":{
    "file":{
      "transfer_method":"local_file",
      "upload_file_id":"a930cf8c-c5c7-4412-8950-bdfe41f91c27",
      "type":"document"
    }
  },
  "query": "帮我优化简历",
  "response_mode":"blocking",
  "user":"test-user-001"
}

执行请求,验证响应:

  • 响应状态码应为200
  • 能收到 AI 返回的简历优化内容

4.服务端(后端)主要代码

后端项目结构如下:

a. pom.xml文件

引入web开发、文件上传、校验等依赖:

XML 复制代码
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>commons-fileupload</groupId>
            <artifactId>commons-fileupload</artifactId>
            <version>1.4</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
b. application.yml
bash 复制代码
spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 10MB
  data:
    redis:
      timeout: 100000000ms
      database: 0
      password:
      port: 6379
      host: localhost

server:
  port: 8080
c. Service业务实现类
java 复制代码
@Service
public class ChatMessageServiceImpl implements ChatMessageService {

    @Autowired
    private RestTemplate restTemplate;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Override
    public ChatMessageResponse sendChatMessageToDify(ChatMessageRequest request) {
        // 1. 从Redis获取FileId(原有逻辑不变)
        String fileId = redisTemplate.opsForValue().get("resume:file:id:" + request.getUser());
        if (fileId == null || fileId.isEmpty()) {
            throw new RuntimeException("请先上传简历文件,再发送优化请求");
        }

        // 2. 构造Dify请求体(修改response_mode为streaming)
        DifyChatMessageRequest difyRequest = new DifyChatMessageRequest();
        difyRequest.setQuery(request.getQuery());
        difyRequest.setResponse_mode("streaming"); // 改为流式模式
        difyRequest.setUser(request.getUser());

        DifyChatMessageRequest.Inputs inputs = new DifyChatMessageRequest.Inputs();
        DifyChatMessageRequest.FileInfo fileInfo = new DifyChatMessageRequest.FileInfo();
        fileInfo.setTransfer_method("local_file");
        fileInfo.setUpload_file_id(fileId);
        fileInfo.setType("document");
        inputs.setFile(fileInfo);
        difyRequest.setInputs(inputs);

        // 3. 设置请求头(原有逻辑不变)
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("Authorization", DifyConstant.AUTH_TOKEN);

        // 4. 调用Dify接口并处理流式响应(核心修改)
        HttpEntity<DifyChatMessageRequest> requestEntity = new HttpEntity<>(difyRequest, headers);
        // 执行请求,获取流式响应
        ResponseEntity<org.springframework.core.io.Resource> response = restTemplate.exchange(
                DifyConstant.CHAT_MESSAGES_URL,
                HttpMethod.POST,
                requestEntity,
                org.springframework.core.io.Resource.class // 接收流式资源
        );

        // 5. 解析流式响应(拼接所有分片结果)
        StringBuilder fullAnswer = new StringBuilder();
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(response.getBody().getInputStream(), StandardCharsets.UTF_8))) {
            String line;
            while ((line = reader.readLine()) != null) {
                // Dify的SSE格式示例:data: {"answer": "优化建议1"}
                if (line.startsWith("data: ")) {
                    String jsonStr = line.substring(6); // 去掉前缀"data: "
                    // 解析JSON片段(可根据Dify实际返回格式调整)
                    // 简化处理:直接拼接answer内容(生产环境建议用Jackson解析)
                    if (jsonStr.contains("\"answer\":")) {
                        // 提取answer值(示例逻辑,需根据Dify实际返回调整)
                        String answer = jsonStr.split("\"answer\":\"")[1].split("\"")[0];
                        fullAnswer.append(answer);
                    }
                }
            }
        } catch (Exception e) {
            throw new RuntimeException("解析流式响应失败:" + e.getMessage());
        }

        // 6. 封装返回结果
        ChatMessageResponse result = new ChatMessageResponse();
        result.setAnswer(fullAnswer.toString());
        return result;
    }
}
java 复制代码
@Service
public class FileUploadServiceImpl implements FileUploadService {

    @Autowired
    private RestTemplate restTemplate;

    // 注入RedisTemplate
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Override
    public FileUploadResponse uploadFileToDify(MultipartFile file, String user) {
        // 1. 校验文件
        if (file.isEmpty()) {
            throw new RuntimeException("上传文件不能为空");
        }

        // 2. 构造Dify文件上传请求
        MultiValueMap<String, Object> requestBody = new LinkedMultiValueMap<>();
        requestBody.add("file", file.getResource()); // 简化文件传递方式
        requestBody.add("user", user);

        // 3. 设置请求头
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.MULTIPART_FORM_DATA);
        headers.set("Authorization", DifyConstant.AUTH_TOKEN);

        // 4. 调用Dify上传接口
        ResponseEntity<FileUploadResponse> response = restTemplate.postForEntity(
                DifyConstant.FILE_UPLOAD_URL,
                new HttpEntity<>(requestBody, headers),
                FileUploadResponse.class
        );

        // 5. 校验响应
        if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
            throw new RuntimeException("Dify文件上传失败,状态码:" + response.getStatusCode());
        }

        // 6. 核心:将user和fileId存入Redis(2小时过期)
        String fileId = response.getBody().getId();
        if (fileId != null && !fileId.isEmpty()) {
            redisTemplate.opsForValue().set(
                    "resume:file:id:" + user, // Redis Key格式:resume:file:id:用户名
                    fileId,
                    2, // 过期时间:2小时
                    TimeUnit.HOURS
            );
        }

        return response.getBody();
    }
}
d. Controller接口类
java 复制代码
@RestController
@RequestMapping("/api/v1/resume")
public class FileUploadController {

    @Autowired
    private FileUploadService fileUploadService;

    /**
     * 上传简历文件(自动缓存FileId)
     */
    @PostMapping("/upload")
    public ResponseEntity<Map<String, Object>> uploadResume(
            @RequestParam("file") MultipartFile file,
            @RequestParam("user") String user) {
        FileUploadResponse response = fileUploadService.uploadFileToDify(file, user);

        // 构造友好的返回结果
        Map<String, Object> result = new HashMap<>();
        result.put("success", true);
        result.put("message", "文件上传成功");
        result.put("fileName", response.getName());
        result.put("fileSize", response.getSize());

        return new ResponseEntity<>(result, HttpStatus.OK);
    }
}
java 复制代码
@RestController
@RequestMapping("/api/v1/resume")
public class ChatMessageController {

    @Autowired
    private ChatMessageService chatMessageService;

    /**
     * 发送简历优化请求(自动关联已上传的文件)
     */
    @PostMapping("/optimize")
    public ResponseEntity<ChatMessageResponse> optimizeResume(
            @Valid @RequestBody ChatMessageRequest request) {
        ChatMessageResponse response = chatMessageService.sendChatMessageToDify(request);
        return new ResponseEntity<>(response, HttpStatus.OK);
    }
}

5.客户端(前端)主要代码

前端项目结构:

a.vite.config.js
javascript 复制代码
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 3000, // 前端运行端口
    proxy: {
      // 代理后端接口解决跨域
      '/api': {
        target: 'http://localhost:8080', // 后端SpringBoot地址
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '/api')
      }
    }
  }
})
b. api
javascript 复制代码
// src/api/constant.js
// 接口基础前缀(与Vite代理配置一致)
export const API_BASE_URL = '/api';
javascript 复制代码
// src/api/request.js
import axios from 'axios';
import { API_BASE_URL } from './constant';

// axios实例(保持不变)
const axiosInstance = axios.create({
  baseURL: API_BASE_URL,
  timeout: 60000
});

axiosInstance.interceptors.response.use(
  (response) => response.data,
  (error) => {
    alert(`请求失败:${error.message}`);
    return Promise.reject(error);
  }
);

/**
 * 修复:用回调函数(onStream)实时传递流式数据,替代Promise
 * @param {string} url - 接口路径
 * @param {object} data - 请求参数
 * @param {function} onStream - 流式数据回调(接收片段)
 * @param {function} onDone - 完成回调(接收完整结果)
 * @param {function} onError - 错误回调
 */
export const sseRequest = (url, data, onStream, onDone, onError) => {
  fetch(`${API_BASE_URL}${url}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(data)
  }).then(response => {
    if (!response.ok) {
      throw new Error(`HTTP错误:${response.status} ${response.statusText}`);
    }
    const reader = response.body.getReader();
    const decoder = new TextDecoder('utf-8');
    let fullAnswer = '';

    const readStream = () => {
  reader.read().then(({ done, value }) => {
    if (done) {
      onDone(fullAnswer);
      return;
    }
    const chunk = decoder.decode(value);
    // 1. 清理多余的``前缀
    const cleanChunk = chunk.replace(/^\s*\s*/g, '');
    if (!cleanChunk.trim()) {
      readStream();
      return;
    }

    try {
      // 2. 解析JSON(后端返回的是{"answer": "..."}格式)
      const res = JSON.parse(cleanChunk);
      if (res.answer) {
        // 3. 解析转义字符(如\\n→\n)
        const parsedAnswer = res.answer.replace(/\\n/g, '\n');
        fullAnswer += parsedAnswer;
        onStream(parsedAnswer); // 传递处理后的内容
      }
    } catch (e) {
      onError(`解析失败:${e.message}`);
    }
    readStream();
  }).catch(error => {
    onError(`流式读取失败:${error.message}`);
  });
};
    readStream();
  }).catch(error => {
    onError(`请求失败:${error.message}`);
  });
};

export default axiosInstance;
javascript 复制代码
// src/api/resumeApi.js
import axiosInstance, { sseRequest } from './request';

export const uploadResumeFile = (formData) => {
  return axiosInstance.post('/v1/resume/upload', formData, {
    headers: {
      'Content-Type': 'multipart/form-data'
    }
  });
};

export const checkHasUploaded = (user) => {
  return axiosInstance.get('/v1/resume/hasFile', { params: { user } });
};

/**
 * 适配回调参数:将params和三个回调传递给sseRequest
 */
export const optimizeResume = (params, onStream, onDone, onError) => {
  sseRequest('/v1/resume/optimize', params, onStream, onDone, onError);
};
c.components
javascript 复制代码
<template>
  <div class="upload-container">
    <h3>📤 上传简历文件</h3>
    <!-- 用户名回显(从父组件传入) -->
    <div class="user-tip" v-if="userValue">当前用户名:{{ userValue }}</div>

    <div class="upload-box">
      <input type="file" ref="fileInput" accept=".pdf,.docx,.doc,.txt" @change="handleFileChange" class="file-input"
        :disabled="!userValue" />
      <button @click="handleUpload" :disabled="!selectedFile || !userValue || isUploading" class="upload-btn">
        {{ isUploading ? '上传中...' : '确认上传' }}
      </button>
    </div>
    <div v-if="uploadMsg" class="upload-msg">{{ uploadMsg }}</div>
  </div>
</template>

<script setup>
import { ref, inject, computed } from 'vue'
import { uploadResumeFile } from '../api/resumeApi'

// 从父组件注入用户名(添加默认值,增强健壮性)
const user = inject('user', ref(''))
// 计算属性:获取user的实际值
const userValue = computed(() => user.value)

// 响应式数据
const fileInput = ref(null)
const selectedFile = ref(null)
const isUploading = ref(false)
const uploadMsg = ref('')

// 选择文件
const handleFileChange = (e) => {
  const file = e.target.files[0]
  if (file) {
    selectedFile.value = file
    uploadMsg.value = `已选择文件:${file.name}`
  }
}

// 上传文件(携带用户输入的用户名)
const handleUpload = async () => {
  if (!selectedFile.value || !userValue.value) return

  isUploading.value = true
  uploadMsg.value = ''

  try {
    // 构造FormData,携带用户输入的user
    const formData = new FormData()
    formData.append('file', selectedFile.value)
    formData.append('user', userValue.value)

    // 调用上传接口
    const res = await uploadResumeFile(formData)
    if (res.success) {
      uploadMsg.value = `✅ 文件上传成功(用户:${userValue.value}):${res.fileName}`
      // 清空文件选择框
      fileInput.value.value = ''
      selectedFile.value = null
    }
  } catch (error) {
    uploadMsg.value = `❌ 上传失败:${error.message}`
  } finally {
    isUploading.value = false
  }
}
</script>
javascript 复制代码
<template>
  <div class="message-container">
    <h3>✍️ 简历优化需求</h3>
    <div class="user-tip" v-if="userValue">当前用户名:{{ userValue }}</div>

    <div class="input-box">
      <input type="text" v-model="query" placeholder="请输入优化需求(如:优化简历,突出Java项目经验)" class="query-input"
        :disabled="!userValue" />
      <button @click="handleOptimize" :disabled="!query || !userValue || isOptimizing" class="optimize-btn">
        {{ isOptimizing ? '处理中...' : '发送优化请求' }}
      </button>
    </div>
  </div>

  <div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
</template>

<script setup>
import { ref, inject, computed } from 'vue'
import { optimizeResume } from '../api/resumeApi'

// 注入用户名
const user = inject('user', ref(''))
const userValue = computed(() => user.value)

// 响应式数据
const query = ref('')
const isOptimizing = ref(false)
const errorMsg = ref('')

// 定义 emits 用于向父组件传递结果
const emit = defineEmits(['update-result'])

// 发送优化请求(用回调接收流式数据)
const handleOptimize = () => {
  if (!query.value || !userValue.value) {
    alert('请输入优化需求和用户名!')
    return
  }

  isOptimizing.value = true
  errorMsg.value = ''

  // 调用优化接口,传入三个回调
  optimizeResume(
    { user: userValue.value, query: query.value },
    // 流式片段回调:实时传递结果给父组件
    (chunk) => {
      emit('update-result', chunk)
    },
    // 完成回调
    () => {
      isOptimizing.value = false
      alert(`✅ 用户名【${userValue.value}】的简历优化完成!`)
    },
    // 错误回调
    (error) => {
      errorMsg.value = `❌ 优化失败:${error}`
      isOptimizing.value = false
    }
  )
}
</script>

6. 运行程序,验证结果

a. 启动前后端与Dify服务
b. 启动redis
c. 前端页面

d. 设置用户名

e. 上传简历文件

f. 查看redis存储的文件id

g. 发送需求消息

h. 得到正确响应
相关推荐
侠客行03178 小时前
Mybatis连接池实现及池化模式
java·mybatis·源码阅读
蛇皮划水怪8 小时前
深入浅出LangChain4J
java·langchain·llm
老毛肚10 小时前
MyBatis体系结构与工作原理 上篇
java·mybatis
风流倜傥唐伯虎10 小时前
Spring Boot Jar包生产级启停脚本
java·运维·spring boot
Yvonne爱编码10 小时前
JAVA数据结构 DAY6-栈和队列
java·开发语言·数据结构·python
Re.不晚10 小时前
JAVA进阶之路——无奖问答挑战1
java·开发语言
你这个代码我看不懂10 小时前
@ConditionalOnProperty不直接使用松绑定规则
java·开发语言
fuquxiaoguang11 小时前
深入浅出:使用MDC构建SpringBoot全链路请求追踪系统
java·spring boot·后端·调用链分析
琹箐11 小时前
最大堆和最小堆 实现思路
java·开发语言·算法
lpruoyu11 小时前
【Docker进阶-03】存储原理
docker·容器