基于SpringAI+Qdrant+Ollama本地模型和向量數(shù)據(jù)庫開發(fā)問答和RAG檢索(完整代碼)
0.前置準(zhǔn)備
1.安裝ollama
然后安裝如下三個模型
C:\Users\GT-JYW-3>ollama list NAME ID SIZE MODIFIED qwen3-embedding:0.6b ac6da0dfba84 639 MB 8 hours ago qwen3.5:2b 324d162be6ca 2.7 GB 25 hours ago deepseek-r1:1.5b e0979632db5a 1.1 GB 25 hours ag
第一個是檢索用的,后面兩個對話用,根據(jù)自己需要安裝別的也行
對話需要帶有(tool),檢索需要帶有(embedding)
2.下載Qdrant
Qdrant是一個本地輕型的向量數(shù)據(jù)庫
下載地址: https://github.com/qdrant/qdrant/releases
windows直接雙擊啟動就行
3.環(huán)境準(zhǔn)備
jdk17,然后下面就是完整的代碼,直接復(fù)制粘貼就可以用
對話直接訪問 ip:port/chat?msg=你的問題。 流式返回就是chatS?msg=
再加一個&sessionId可以基于redis進(jìn)行會話存儲(使用的是DB:6)
文件檢索這個我寫死了個目錄,可以自行修改和擴(kuò)展在KnowledgeBaseConfig.java>defaultPath
然后訪問ip:port/file/scan 先進(jìn)行索引創(chuàng)建 然后再/file/chat?msg=你的問題即可
代碼99%都是AI寫的,本人親測是可以讀取文件夾里面PPT,MD,DOCX內(nèi)容的
只是模型太笨,可以換個更大的模型,這里用的幾個都是很小的來測試的
1.整體結(jié)構(gòu)

2.pom文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
<relativePath/>
</parent>
<groupId>com.cxl</groupId>
<artifactId>springai-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springai-demo</name>
<description>springai-demo</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>17</java.version>
<spring-ai.version>1.0.0-M6</spring-ai.version>
</properties>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qdrant-store-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version> <!-- 替換為實際版本 -->
</dependency>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring AI Core -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-core</artifactId>
</dependency>
<!-- Spring AI Ollama Starter -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
<!-- Spring Boot DevTools -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Apache POI for PPTX extraction -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
</dependency>
<!-- Spring Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Spring AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>3.application.yaml文件
# ===================================================================
# Spring AI Demo 配置文件
# ===================================================================
# 服務(wù)器配置
server:
port: 12000 # 應(yīng)用端口
# Spring 配置
spring:
application:
name: springai-demo # 應(yīng)用名稱
# Redis 配置 - 用于存儲對話歷史
data:
redis:
host: localhost # Redis 服務(wù)器地址
port: 6379 # Redis 端口
database: 6 # Redis 數(shù)據(jù)庫編號 (0-15)
# AI 大模型配置
ai:
# Ollama 本地大模型服務(wù)配置
ollama:
base-url: http://localhost:11434 # Ollama API 地址
embedding:
model: qwen3-embedding:0.6b # 嵌入模型 (用于 RAG 向量化)
enabled: true
# 嵌入向量配置
embedding:
options:
model: qwen3-embedding:0.6b # 嵌入模型名稱
# 向量數(shù)據(jù)庫配置 (Qdrant)
vectorstore:
qdrant:
host: localhost # Qdrant 服務(wù)地址
port: 6334 # Qdrant gRPC 端口 (REST API 端口為 6333)
collection-name: my_docs # 向量集合名稱
# ===================================================================
# 知識庫配置 (Knowledge Base)
# 用途: FileSearchService 用于 RAG 知識檢索
# ===================================================================
knowledge-base:
default-path: "C:\\Users\\GT-JYW-3\\Documents\\doct" # 知識庫文件默認(rèn)目錄
collection-name: my_docs # Qdrant 向量集合名稱
host: localhost # Qdrant 服務(wù)地址
port: 6334 # Qdrant 端口
top-k: 5 # 知識檢索返回的相關(guān)文檔數(shù)量
# ===================================================================
# 對話歷史配置 (Chat History)
# 用途: ChatHistoryService + ChatHistoryAspect 用于存儲會話上下文
# ===================================================================
chat-history:
enabled: true # 是否啟用對話歷史功能
key-prefix: "chat:history:" # Redis Key 前綴
max-size: 20 # 每個會話最大保存的消息數(shù)量
expire-days: 7 # 歷史記錄過期天數(shù)
# ===================================================================
# System 預(yù)制詞配置 (AI 角色設(shè)定)
# 用途: ChatService 調(diào)用不同模型時使用的系統(tǒng)提示詞
# ===================================================================
system:
settings:
# 默認(rèn)提示詞 (當(dāng)模型沒有特定提示詞時使用)
default-prompt: "你是一個有幫助的AI助手,請用簡潔專業(yè)的語言回答用戶問題。"
# 針對不同模型的特定提示詞
prompts:
# Qwen 模型提示詞
qwen3.5:2b: "你是Qwen3.5模型,一個由阿里云開發(fā)的大語言模型。你擅長中文理解和生成,請用專業(yè)、準(zhǔn)確的語言回答用戶問題。"
# DeepSeek 模型提示詞
deepseek-r1:1.5b: "你是DeepSeek-R1模型,一個專注于推理和思考的AI助手。請在回答問題時展示清晰的思考過程,提供深入的分析。"
# ===================================================================
# 應(yīng)用全局配置
# ===================================================================
app:
context-aware-enabled: true # 是否啟用上下文感知功能
default-top-k: 3 # 默認(rèn)知識檢索數(shù)量
session-timeout-minutes: 60 # 會話超時時間(分鐘)4.啟用對話記憶注解
package com.cxl.annotation;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface EnableChatHistory {
boolean enabled() default true;
}5.對話記憶注解切面
package com.cxl.aspect;
import com.cxl.annotation.EnableChatHistory;
import com.cxl.service.ChatHistoryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import java.lang.reflect.Method;
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class ChatHistoryAspect {
private final ChatHistoryService chatHistoryService;
@Around("@annotation(com.cxl.annotation.EnableChatHistory)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
EnableChatHistory annotation = method.getAnnotation(EnableChatHistory.class);
if (!annotation.enabled()) {
return joinPoint.proceed();
}
Object[] args = joinPoint.getArgs();
String[] paramNames = signature.getParameterNames();
String sessionId = null;
String userMessage = null;
for (int i = 0; i < args.length; i++) {
String paramName = paramNames[i];
if ("sessionId".equals(paramName) && args[i] != null) {
sessionId = (String) args[i];
}
if ("msg".equals(paramName) && args[i] != null) {
userMessage = (String) args[i];
}
}
if (sessionId == null || sessionId.isEmpty()) {
return joinPoint.proceed();
}
final String finalSessionId = sessionId;
final String finalUserMessage = userMessage;
log.info("========== ChatHistoryAspect ==========");
log.info("SessionId: {}", finalSessionId);
log.info("UserMessage: {}", finalUserMessage);
Object result = joinPoint.proceed();
if (result instanceof Flux) {
Flux<String> fluxResult = (Flux<String>) result;
return fluxResult
.collectList()
.doOnNext(chunks -> {
String fullResponse = String.join("", chunks);
chatHistoryService.addMessage(finalSessionId, "user", finalUserMessage);
chatHistoryService.addMessage(finalSessionId, "assistant", fullResponse);
log.info("Saved stream chat history to Redis");
})
.flatMapMany(chunks -> Flux.fromIterable(chunks));
} else if (result instanceof String) {
chatHistoryService.addMessage(finalSessionId, "user", finalUserMessage);
chatHistoryService.addMessage(finalSessionId, "assistant", (String) result);
log.info("Saved chat history to Redis");
}
return result;
}
}6.配置類
1.AIConfig
package com.cxl.config;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.ollama.OllamaEmbeddingModel;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaOptions;
import org.springframework.ai.ollama.management.ModelManagementOptions;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import java.util.List;
@Configuration
public class AIConfig {
@Bean
public OllamaApi ollamaApi() {
return new OllamaApi("http://localhost:11434");
}
@Bean
public ObservationRegistry observationRegistry() {
return ObservationRegistry.NOOP;
}
@Bean
@Primary
public ChatModel qwenChatModel(OllamaApi ollamaApi, ObservationRegistry observationRegistry) {
return new OllamaChatModel(
ollamaApi,
OllamaOptions.builder().model("qwen3.5:2b").build(),
null,
List.of(),
observationRegistry,
ModelManagementOptions.builder().build()
);
}
@Bean("deepseekChatModel")
public ChatModel deepseekChatModel(OllamaApi ollamaApi, ObservationRegistry observationRegistry) {
return new OllamaChatModel(
ollamaApi,
OllamaOptions.builder().model("deepseek-r1:1.5b").build(),
null,
List.of(),
observationRegistry,
ModelManagementOptions.builder().build()
);
}
@Bean
public EmbeddingModel embeddingModel(OllamaApi ollamaApi, ObservationRegistry observationRegistry) {
return new OllamaEmbeddingModel(
ollamaApi,
OllamaOptions.builder().model("qwen3-embedding:0.6b").build(),
observationRegistry,
ModelManagementOptions.builder().build()
);
}
}2.AppConfig
package com.cxl.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 全局配置類
* 管理應(yīng)用的全局開關(guān)和配置
*/
@Data
@Component
@ConfigurationProperties(prefix = "app")
public class AppConfig {
/**
* 上下文感知全局開關(guān)
* true: 啟用上下文感知
* false: 禁用上下文感知
*/
private boolean contextAwareEnabled = true;
/**
* 文件檢索默認(rèn)返回數(shù)量
*/
private int defaultTopK = 3;
/**
* 會話超時時間(分鐘)
*/
private int sessionTimeoutMinutes = 60;
/**
* 文件掃描的最大文件大?。ㄗ止?jié))
*/
private long maxFileSize = 10 * 1024 * 1024; // 10MB
/**
* 支持的文件類型
*/
private String[] supportedFileTypes = {
"txt", "md", "json", "xml", "html", "css", "js", "java",
"pdf", "pptx", "ppt",
"jpg", "jpeg", "png", "gif", "webp",
"mp4", "avi", "mov", "wmv"
};
}3.對話客戶端配置
package com.cxl.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@Configuration
public class ChatClientConfig {
@Bean
@Primary
public ChatClient qwenChatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel).build();
}
@Bean("deepseekChatClient")
public ChatClient deepseekChatClient(@Qualifier("deepseekChatModel") ChatModel chatModel) {
return ChatClient.builder(chatModel).build();
}
}4.知識庫配置(向量數(shù)據(jù)庫)
package com.cxl.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "knowledge-base")
public class KnowledgeBaseConfig {
private String defaultPath = "C:\\Users\\GT-JYW-3\\Documents\\doct";
private String collectionName = "my_docs";
private String host = "localhost";
private int port = 6334;
private int topK = 5;
public String getDefaultPath() {
return defaultPath;
}
public void setDefaultPath(String defaultPath) {
this.defaultPath = defaultPath;
}
public String getCollectionName() {
return collectionName;
}
public void setCollectionName(String collectionName) {
this.collectionName = collectionName;
}
public String getHost() {
return host;
}
public void setHost(String host) {
this.host = host;
}
public int getPort() {
return port;
}
public void setPort(int port) {
this.port = port;
}
public int getTopK() {
return topK;
}
public void setTopK(int topK) {
this.topK = topK;
}
}5.系統(tǒng)配置
package com.cxl.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* System預(yù)制詞配置類
* 用于管理不同模型的System預(yù)制詞
*/
@Data
@Component
@ConfigurationProperties(prefix = "system.settings")
public class SystemSettings {
/**
* 不同模型的System預(yù)制詞配置
* key: 模型名稱
* value: System預(yù)制詞內(nèi)容
*/
private Map<String, String> prompts = new HashMap<>();
/**
* 默認(rèn)System預(yù)制詞
*/
private String defaultPrompt = "你是一個有幫助的AI助手。";
/**
* 根據(jù)模型名稱獲取對應(yīng)的System預(yù)制詞
* @param modelName 模型名稱
* @return System預(yù)制詞
*/
public String getSystemPrompt(String modelName) {
return prompts.getOrDefault(modelName, defaultPrompt);
}
}7.接口
1.基本對話&流式返回
package com.cxl.controller;
import com.cxl.annotation.EnableChatHistory;
import com.cxl.service.ChatService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
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;
import reactor.core.publisher.Flux;
@RestController
@RequestMapping
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
@EnableChatHistory
@GetMapping("/chat")
public String chat(
@RequestParam String msg,
@RequestParam(required = false) String sessionId,
@RequestParam(defaultValue = "qwen") String model) {
return chatService.chat(msg, model, sessionId);
}
@EnableChatHistory
@GetMapping(value = "/chatS", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(
@RequestParam String msg,
@RequestParam(required = false) String sessionId,
@RequestParam(defaultValue = "qwen") String model) {
return chatService.chatStreamRaw(msg, model, sessionId);
}
}2.基于文件目錄對話
package com.cxl.controller;
import com.cxl.annotation.EnableChatHistory;
import com.cxl.service.ChatHistoryService;
import com.cxl.service.ChatService;
import com.cxl.service.FileSearchService;
import com.cxl.service.FileSearchService.DocumentInfo;
import com.cxl.service.SessionManagerService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/file")
@RequiredArgsConstructor
public class FileSearchController {
private final FileSearchService fileSearchService;
private final ChatService chatService;
private final SessionManagerService sessionManagerService;
private final ChatHistoryService chatHistoryService;
@GetMapping("/scan")
public Map<String, Object> scanDirectory(
@RequestParam(required = false) String directory,
@RequestParam(required = false) String sessionId) {
if (sessionId != null) {
SessionManagerService.SessionInfo session = sessionManagerService.getOrCreateSession(sessionId);
session.updateLastActivity();
}
int fileCount = fileSearchService.scanDirectory(directory);
Map<String, Object> result = new HashMap<>();
result.put("status", "success");
result.put("message", "文件夾掃描完成");
result.put("fileCount", fileCount);
return result;
}
@EnableChatHistory
@GetMapping("/chat")
public String fileChat(
@RequestParam String msg,
@RequestParam String sessionId,
@RequestParam(defaultValue = "qwen") String model,
@RequestParam(required = false) Boolean contextAware,
@RequestParam(defaultValue = "3") int topK,
@RequestParam(required = false) String fileType) {
SessionManagerService.SessionInfo session = sessionManagerService.getOrCreateSession(sessionId);
session.updateLastActivity();
boolean useContextAware = session.isContextAware();
if (contextAware != null) {
useContextAware = contextAware;
session.setContextAware(useContextAware);
}
Map<String, Object> filters = new HashMap<>();
if (fileType != null && !fileType.isEmpty()) {
filters.put("filetype", fileType);
}
List<DocumentInfo> documents = fileSearchService.search(msg, topK, filters);
StringBuilder context = new StringBuilder();
if (useContextAware && !documents.isEmpty()) {
context.append("根據(jù)以下文件內(nèi)容回答問題:\n\n");
for (DocumentInfo doc : documents) {
context.append("文件: ").append(doc.filename)
.append("\n路徑: ").append(doc.path)
.append("\n內(nèi)容: ").append(doc.content.substring(0, Math.min(500, doc.content.length())))
.append("...\n\n");
}
}
String prompt = context.toString() + "用戶問題: " + msg;
return chatService.chat(prompt, model, sessionId);
}
@EnableChatHistory
@GetMapping(value = "/chatS", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> fileChatStream(
@RequestParam String msg,
@RequestParam String sessionId,
@RequestParam(defaultValue = "qwen") String model,
@RequestParam(required = false) Boolean contextAware,
@RequestParam(defaultValue = "3") int topK,
@RequestParam(required = false) String fileType) {
SessionManagerService.SessionInfo session = sessionManagerService.getOrCreateSession(sessionId);
session.updateLastActivity();
boolean useContextAware = session.isContextAware();
if (contextAware != null) {
useContextAware = contextAware;
session.setContextAware(useContextAware);
}
Map<String, Object> filters = new HashMap<>();
if (fileType != null && !fileType.isEmpty()) {
filters.put("filetype", fileType);
}
List<DocumentInfo> documents = fileSearchService.search(msg, topK, filters);
StringBuilder context = new StringBuilder();
if (useContextAware && !documents.isEmpty()) {
context.append("根據(jù)以下文件內(nèi)容回答問題:\n\n");
for (DocumentInfo doc : documents) {
context.append("文件: ").append(doc.filename)
.append("\n路徑: ").append(doc.path)
.append("\n內(nèi)容: ").append(doc.content.substring(0, Math.min(500, doc.content.length())))
.append("...\n\n");
}
}
String prompt = context.toString() + "用戶問題: " + msg;
return chatService.chatStreamRaw(prompt, model, sessionId);
}
@GetMapping("/clear")
public Map<String, Object> clearAll(@RequestParam(required = false) String sessionId) {
fileSearchService.clearAll();
if (sessionId != null) {
chatHistoryService.clearHistory(sessionId);
}
Map<String, Object> result = new HashMap<>();
result.put("status", "success");
result.put("message", sessionId != null
? "已清除向量數(shù)據(jù)庫和會話歷史記錄"
: "已清除向量數(shù)據(jù)庫中的所有文檔");
return result;
}
@GetMapping("/context")
public Map<String, Object> setContextAware(
@RequestParam String sessionId,
@RequestParam boolean contextAware) {
SessionManagerService.SessionInfo session = sessionManagerService.getOrCreateSession(sessionId);
session.setContextAware(contextAware);
Map<String, Object> result = new HashMap<>();
result.put("status", "success");
result.put("contextAware", contextAware);
result.put("sessionId", sessionId);
return result;
}
}8.Service
1.ChatHistoryService
package com.cxl.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@ConfigurationProperties(prefix = "chat-history")
public class ChatHistoryService {
private String keyPrefix = "chat:history:";
private int maxSize = 20;
private int expireDays = 7;
private boolean enabled = true;
private final StringRedisTemplate redisTemplate;
public ChatHistoryService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void addMessage(String sessionId, String role, String content) {
if (!enabled) {
log.debug("Chat history is disabled, skipping save");
return;
}
if (sessionId == null || sessionId.isEmpty()) {
log.warn("sessionId is null or empty, skipping save");
return;
}
String key = keyPrefix + sessionId;
String message = String.format("%s|%s|%s",
LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
role,
content
);
try {
redisTemplate.opsForList().rightPush(key, message);
redisTemplate.opsForList().trim(key, -maxSize, -1);
redisTemplate.expire(key, expireDays, TimeUnit.DAYS);
log.info("Saved message to Redis - sessionId: {}, role: {}, key: {}", sessionId, role, key);
} catch (Exception e) {
log.error("Failed to save message to Redis: {}", e.getMessage(), e);
}
}
public List<ChatMessage> getHistory(String sessionId) {
if (!enabled) {
log.debug("Chat history is disabled, returning empty history");
return new ArrayList<>();
}
if (sessionId == null || sessionId.isEmpty()) {
log.warn("sessionId is null or empty, returning empty history");
return new ArrayList<>();
}
String key = keyPrefix + sessionId;
log.info("Getting history from Redis - sessionId: {}, key: {}", sessionId, key);
try {
List<String> rawMessages = redisTemplate.opsForList().range(key, 0, -1);
if (rawMessages == null || rawMessages.isEmpty()) {
log.info("No history found in Redis for sessionId: {}", sessionId);
return new ArrayList<>();
}
log.info("Found {} messages in Redis for sessionId: {}", rawMessages.size(), sessionId);
List<ChatMessage> messages = new ArrayList<>();
for (String raw : rawMessages) {
String[] parts = raw.split("\\|", 3);
if (parts.length >= 3) {
ChatMessage msg = new ChatMessage();
msg.setTimestamp(parts[0]);
msg.setRole(parts[1]);
msg.setContent(parts[2]);
messages.add(msg);
}
}
return messages;
} catch (Exception e) {
log.error("Failed to get history from Redis: {}", e.getMessage(), e);
return new ArrayList<>();
}
}
public String getLastUserMessage(String sessionId) {
List<ChatMessage> history = getHistory(sessionId);
for (int i = history.size() - 1; i >= 0; i--) {
if ("user".equals(history.get(i).getRole())) {
return history.get(i).getContent();
}
}
return null;
}
public void clearHistory(String sessionId) {
String key = keyPrefix + sessionId;
redisTemplate.delete(key);
}
public String getKeyPrefix() { return keyPrefix; }
public void setKeyPrefix(String keyPrefix) { this.keyPrefix = keyPrefix; }
public int getMaxSize() { return maxSize; }
public void setMaxSize(int maxSize) { this.maxSize = maxSize; }
public int getExpireDays() { return expireDays; }
public void setExpireDays(int expireDays) { this.expireDays = expireDays; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public static class ChatMessage {
private String timestamp;
private String role;
private String content;
public String getTimestamp() { return timestamp; }
public void setTimestamp(String timestamp) { this.timestamp = timestamp; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
}
}2.ChatService
package com.cxl.service;
import com.cxl.config.SystemSettings;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Service
public class ChatService {
private final ChatClient qwenChatClient;
private final ChatClient deepseekChatClient;
private final SystemSettings systemSettings;
private final ChatHistoryService chatHistoryService;
private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public ChatService(
ChatClient qwenChatClient,
@Qualifier("deepseekChatClient") ChatClient deepseekChatClient,
SystemSettings systemSettings,
ChatHistoryService chatHistoryService) {
this.qwenChatClient = qwenChatClient;
this.deepseekChatClient = deepseekChatClient;
this.systemSettings = systemSettings;
this.chatHistoryService = chatHistoryService;
}
public String chat(String msg, String model) {
return chatWithHistory(msg, model, null);
}
public String chat(String msg, String model, String sessionId) {
return chatWithHistory(msg, model, sessionId);
}
private String chatWithHistory(String msg, String model, String sessionId) {
String startTime = LocalDateTime.now().format(formatter);
log.info("========== 對話開始 ==========");
log.info("開始時間: {}", startTime);
log.info("使用模型: {}", model);
log.info("用戶消息: {}", msg);
if (sessionId != null) {
log.info("會話ID: {}", sessionId);
}
ChatClient chatClient = getChatClient(model);
String modelName = getModelName(model);
String systemPrompt = systemSettings.getSystemPrompt(modelName);
log.info("System預(yù)制詞: {}", systemPrompt);
long startMillis = System.currentTimeMillis();
ChatClient.ChatClientRequestSpec promptSpec = chatClient.prompt()
.system(systemPrompt);
if (sessionId != null) {
List<ChatHistoryService.ChatMessage> history = chatHistoryService.getHistory(sessionId);
if (!history.isEmpty()) {
StringBuilder historyContext = new StringBuilder();
historyContext.append("\n以下是之前的對話歷史:\n");
for (ChatHistoryService.ChatMessage chatMsg : history) {
historyContext.append(chatMsg.getRole()).append(": ").append(chatMsg.getContent()).append("\n");
}
promptSpec = promptSpec.system(systemPrompt + historyContext.toString());
}
}
String content = promptSpec.user(msg).call().content();
long endMillis = System.currentTimeMillis();
log.info("響應(yīng)耗時: {} ms", endMillis - startMillis);
log.info("AI回復(fù): {}", content);
log.info("========== 對話結(jié)束 ==========\n");
return content;
}
public Flux<String> chatStreamRaw(String msg, String model) {
return chatStreamWithHistory(msg, model, null);
}
public Flux<String> chatStreamRaw(String msg, String model, String sessionId) {
return chatStreamWithHistory(msg, model, sessionId);
}
private Flux<String> chatStreamWithHistory(String msg, String model, String sessionId) {
String startTime = LocalDateTime.now().format(formatter);
log.info("========== 流式對話開始 ==========");
log.info("開始時間: {}", startTime);
log.info("使用模型: {}", model);
log.info("用戶消息: {}", msg);
if (sessionId != null) {
log.info("會話ID: {}", sessionId);
}
ChatClient chatClient = getChatClient(model);
String modelName = getModelName(model);
String systemPrompt = systemSettings.getSystemPrompt(modelName);
log.info("System預(yù)制詞: {}", systemPrompt);
long startMillis = System.currentTimeMillis();
ChatClient.ChatClientRequestSpec promptSpec = chatClient.prompt()
.system(systemPrompt);
if (sessionId != null) {
List<ChatHistoryService.ChatMessage> history = chatHistoryService.getHistory(sessionId);
if (!history.isEmpty()) {
StringBuilder historyContext = new StringBuilder();
historyContext.append("\n以下是之前的對話歷史:\n");
for (ChatHistoryService.ChatMessage chatMsg : history) {
historyContext.append(chatMsg.getRole()).append(": ").append(chatMsg.getContent()).append("\n");
}
promptSpec = promptSpec.system(systemPrompt + historyContext.toString());
}
}
return promptSpec.user(msg)
.stream()
.content()
.doOnComplete(() -> {
long endMillis = System.currentTimeMillis();
log.info("響應(yīng)耗時: {} ms", endMillis - startMillis);
log.info("========== 流式對話結(jié)束 ==========\n");
})
.doOnError(error -> {
log.error("流式對話發(fā)生錯誤: {}", error.getMessage(), error);
});
}
private ChatClient getChatClient(String model) {
if ("deepseek".equalsIgnoreCase(model)) {
return deepseekChatClient;
}
return qwenChatClient;
}
private String getModelName(String model) {
if ("deepseek".equalsIgnoreCase(model)) {
return "deepseek-r1:1.5b";
}
return "qwen3.5:2b";
}
}3.FileSearchService
package com.cxl.service;
import com.cxl.config.KnowledgeBaseConfig;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.xslf.usermodel.XMLSlideShow;
import org.apache.poi.xslf.usermodel.XSLFSlide;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.Filter.Expression;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
public class FileSearchService {
private final VectorStore vectorStore;
private final KnowledgeBaseConfig knowledgeBaseConfig;
public FileSearchService(VectorStore vectorStore, KnowledgeBaseConfig knowledgeBaseConfig) {
this.vectorStore = vectorStore;
this.knowledgeBaseConfig = knowledgeBaseConfig;
}
public int scanDirectory(String directoryPath) {
String actualPath = (directoryPath == null || directoryPath.isEmpty())
? knowledgeBaseConfig.getDefaultPath()
: directoryPath;
log.info("開始掃描文件夾: {}", actualPath);
List<Document> documents = new ArrayList<>();
File directory = new File(actualPath);
if (!directory.exists() || !directory.isDirectory()) {
log.error("文件夾不存在或不是目錄: {}", actualPath);
return 0;
}
try {
scanFile(directory, documents);
if (!documents.isEmpty()) {
vectorStore.add(documents);
log.info("成功掃描 {} 個文件并添加到向量數(shù)據(jù)庫", documents.size());
}
} catch (Exception e) {
log.error("掃描文件夾失敗: {}", e.getMessage(), e);
return 0;
}
return documents.size();
}
private void scanFile(File file, List<Document> documents) throws IOException {
if (file.isDirectory()) {
File[] files = file.listFiles();
if (files != null) {
for (File f : files) {
scanFile(f, documents);
}
}
} else {
String content = extractContent(file);
if (content != null && !content.trim().isEmpty()) {
Map<String, Object> metadata = new HashMap<>();
metadata.put("filename", file.getName());
metadata.put("path", file.getAbsolutePath());
metadata.put("filetype", getFileType(file.getName()));
metadata.put("extension", getFileExtension(file.getName()));
metadata.put("size", (int) file.length());
Document doc = Document.builder()
.id(UUID.randomUUID().toString())
.text(content)
.metadata(metadata)
.build();
documents.add(doc);
}
}
}
private String extractContent(File file) {
try {
String extension = getFileExtension(file.getName()).toLowerCase();
Path path = Paths.get(file.getAbsolutePath());
if (extension.equals("txt") || extension.equals("md") || extension.equals("json") ||
extension.equals("xml") || extension.equals("html") || extension.equals("css") ||
extension.equals("js") || extension.equals("java") || extension.equals("py") ||
extension.equals("go") || extension.equals("rs") || extension.equals("c") ||
extension.equals("cpp") || extension.equals("h") || extension.equals("sql")) {
return Files.readString(path);
} else if (extension.equals("pptx") || extension.equals("ppt")) {
return extractPptContent(file);
} else {
return String.format("文件名: %s\n路徑: %s\n文件大小: %d bytes\n文件類型: %s",
file.getName(), file.getAbsolutePath(), file.length(), getFileType(file.getName()));
}
} catch (Exception e) {
log.warn("提取文件內(nèi)容失敗: {} - {}", file.getAbsolutePath(), e.getMessage());
return null;
}
}
private String extractPptContent(File file) {
String extension = getFileExtension(file.getName()).toLowerCase();
StringBuilder content = new StringBuilder();
content.append("文件名: ").append(file.getName()).append("\n");
content.append("路徑: ").append(file.getAbsolutePath()).append("\n\n");
if (!extension.equals("pptx")) {
return String.format("文件名: %s\n路徑: %s\n文件大小: %d bytes\n文件類型: PPT(僅支持.pptx格式)",
file.getName(), file.getAbsolutePath(), file.length());
}
try (FileInputStream fis = new FileInputStream(file);
XMLSlideShow ppt = new XMLSlideShow(fis)) {
List<XSLFSlide> slides = ppt.getSlides();
for (int i = 0; i < slides.size(); i++) {
XSLFSlide slide = slides.get(i);
content.append("=== 第 ").append(i + 1).append(" 頁 ===\n");
for (Object shape : slide.getShapes()) {
if (shape instanceof org.apache.poi.xslf.usermodel.XSLFTextShape) {
org.apache.poi.xslf.usermodel.XSLFTextShape textShape =
(org.apache.poi.xslf.usermodel.XSLFTextShape) shape;
String text = textShape.getText();
if (text != null && !text.trim().isEmpty()) {
content.append(text).append("\n");
}
}
}
content.append("\n");
}
} catch (Exception e) {
log.warn("提取PPT內(nèi)容失敗: {} - {}", file.getAbsolutePath(), e.getMessage());
return String.format("文件名: %s\n路徑: %s\n文件大小: %d bytes\n文件類型: PPT",
file.getName(), file.getAbsolutePath(), file.length());
}
return content.toString();
}
private String getFileType(String filename) {
String extension = getFileExtension(filename).toLowerCase();
switch (extension) {
case "txt": case "md": case "json": case "xml": case "html":
case "css": case "js": case "java": case "py": case "go": case "rs":
case "c": case "cpp": case "h": case "sql":
return "text";
case "pdf": case "doc": case "docx": return "document";
case "pptx": case "ppt": return "presentation";
case "jpg": case "jpeg": case "png": case "gif": case "webp": return "image";
case "mp4": case "avi": case "mov": case "wmv": return "video";
default: return "other";
}
}
private String getFileExtension(String filename) {
int lastDotIndex = filename.lastIndexOf('.');
return lastDotIndex > 0 ? filename.substring(lastDotIndex + 1) : "";
}
public List<DocumentInfo> search(String query, int topK, Map<String, Object> filters) {
log.info("開始檢索: {}", query);
try {
int actualTopK = (topK > 0) ? topK : knowledgeBaseConfig.getTopK();
SearchRequest.Builder builder = SearchRequest.builder()
.query(query)
.topK(actualTopK);
if (filters != null && filters.containsKey("filetype")) {
String filterType = (String) filters.get("filetype");
Expression filter = new FilterExpressionBuilder()
.eq("filetype", filterType)
.build();
builder.filterExpression(filter);
}
List<org.springframework.ai.document.Document> results = vectorStore.similaritySearch(builder.build());
return results.stream()
.map(this::toDocumentInfo)
.collect(Collectors.toList());
} catch (Exception e) {
log.error("檢索失敗: {}", e.getMessage(), e);
return new ArrayList<>();
}
}
private DocumentInfo toDocumentInfo(org.springframework.ai.document.Document doc) {
DocumentInfo info = new DocumentInfo();
info.id = doc.getId();
info.content = doc.getText();
info.filename = (String) doc.getMetadata().getOrDefault("filename", "");
info.path = (String) doc.getMetadata().getOrDefault("path", "");
info.filetype = (String) doc.getMetadata().getOrDefault("filetype", "");
info.extension = (String) doc.getMetadata().getOrDefault("extension", "");
Object sizeObj = doc.getMetadata().get("size");
info.size = (sizeObj != null) ? ((Number) sizeObj).longValue() : 0L;
return info;
}
public void clearAll() {
try {
SearchRequest searchRequest = SearchRequest.builder()
.query("*")
.topK(1000)
.build();
List<org.springframework.ai.document.Document> allDocs = vectorStore.similaritySearch(searchRequest);
List<String> ids = allDocs.stream()
.map(org.springframework.ai.document.Document::getId)
.collect(Collectors.toList());
if (!ids.isEmpty()) {
vectorStore.delete(ids);
}
log.info("已清除向量數(shù)據(jù)庫中的所有文檔,共 {} 條", ids.size());
} catch (Exception e) {
log.error("清除向量數(shù)據(jù)庫失敗: {}", e.getMessage(), e);
}
}
public static class DocumentInfo {
public String id;
public String filename;
public String path;
public String content;
public String filetype;
public String extension;
public long size;
}
}4.SessionManagerService
package com.cxl.service;
import lombok.Data;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@Service
public class SessionManagerService {
private final Map<String, SessionInfo> sessions = new ConcurrentHashMap<>();
private final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);
private int sessionTimeoutMinutes = 60;
public SessionManagerService() {
executorService.scheduleAtFixedRate(this::cleanupExpiredSessions,
sessionTimeoutMinutes,
sessionTimeoutMinutes,
TimeUnit.MINUTES);
}
public SessionInfo getOrCreateSession(String sessionId) {
return sessions.computeIfAbsent(sessionId, id -> {
SessionInfo session = new SessionInfo(id);
return session;
});
}
public void removeSession(String sessionId) {
sessions.remove(sessionId);
}
private void cleanupExpiredSessions() {
long now = System.currentTimeMillis();
long timeoutMs = sessionTimeoutMinutes * 60 * 1000;
sessions.entrySet().removeIf(entry -> {
SessionInfo session = entry.getValue();
boolean expired = now - session.getLastActivityTime() > timeoutMs;
return expired;
});
}
@Data
public static class SessionInfo {
private final String sessionId;
private long lastActivityTime;
private boolean contextAware = true;
public SessionInfo(String sessionId) {
this.sessionId = sessionId;
this.lastActivityTime = System.currentTimeMillis();
}
public void updateLastActivity() {
this.lastActivityTime = System.currentTimeMillis();
}
}
}9.向量數(shù)據(jù)庫初始化
package com.cxl;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class QdrantCollectionCreator {
private static final String COLLECTION_NAME = "my_docs";
private static final int VECTOR_DIMENSION = 1024;
private static final String HOST = "localhost";
private static final int PORT = 6333;
public static void main(String[] args) {
System.out.println("=== Qdrant Collection Creator ===");
HttpClient client = HttpClient.newHttpClient();
System.out.println("\n1. Checking Qdrant service availability...");
String healthUrl = "http://" + HOST + ":" + PORT + "/";
try {
HttpRequest healthRequest = HttpRequest.newBuilder()
.uri(URI.create(healthUrl))
.GET()
.build();
HttpResponse<String> healthResponse = client.send(healthRequest, HttpResponse.BodyHandlers.ofString());
System.out.println("Qdrant service status: " + healthResponse.statusCode());
System.out.println("Response: " + healthResponse.body());
} catch (Exception e) {
System.err.println("ERROR: Cannot connect to Qdrant at http://" + HOST + ":" + PORT);
return;
}
System.out.println("\n2. Listing available collections...");
String listUrl = "http://" + HOST + ":" + PORT + "/collections";
try {
HttpRequest listRequest = HttpRequest.newBuilder()
.uri(URI.create(listUrl))
.GET()
.build();
HttpResponse<String> listResponse = client.send(listRequest, HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + listResponse.statusCode());
System.out.println("Response: " + listResponse.body());
} catch (Exception e) {
System.out.println("Error: " + e.getMessage());
}
System.out.println("\n3. Checking if collection exists...");
String checkUrl = "http://" + HOST + ":" + PORT + "/collections/" + COLLECTION_NAME;
try {
HttpRequest checkRequest = HttpRequest.newBuilder()
.uri(URI.create(checkUrl))
.GET()
.build();
HttpResponse<String> checkResponse = client.send(checkRequest, HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + checkResponse.statusCode());
if (checkResponse.statusCode() == 200) {
System.out.println("Collection '" + COLLECTION_NAME + "' already exists!");
return;
}
} catch (Exception e) {
System.out.println("Collection does not exist. Will create it...");
}
System.out.println("\n4. Creating collection with PUT...");
String createUrl = "http://" + HOST + ":" + PORT + "/collections/" + COLLECTION_NAME;
String requestBody = String.format("""
{
"vectors": {
"size": %d,
"distance": "Cosine"
}
}
""", VECTOR_DIMENSION);
try {
HttpRequest createRequest = HttpRequest.newBuilder()
.uri(URI.create(createUrl))
.header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> createResponse = client.send(createRequest, HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + createResponse.statusCode());
System.out.println("Response: " + createResponse.body());
if (createResponse.statusCode() == 200 || createResponse.statusCode() == 201) {
System.out.println("\n? Collection '" + COLLECTION_NAME + "' created successfully!");
return;
}
} catch (Exception e) {
System.err.println("Error with PUT: " + e.getMessage());
}
System.out.println("\n5. Trying POST to /collections endpoint...");
String postUrl = "http://" + HOST + ":" + PORT + "/collections";
try {
HttpRequest postRequest = HttpRequest.newBuilder()
.uri(URI.create(postUrl))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> postResponse = client.send(postRequest, HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + postResponse.statusCode());
System.out.println("Response: " + postResponse.body());
if (postResponse.statusCode() == 200 || postResponse.statusCode() == 201) {
System.out.println("\n? Collection '" + COLLECTION_NAME + "' created successfully!");
} else {
System.err.println("\n? Failed to create collection");
}
} catch (Exception e) {
System.err.println("Error with POST: " + e.getMessage());
}
}
}10.啟動類
package com.cxl;
import com.cxl.config.AppConfig;
import com.cxl.config.SystemSettings;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication
@EnableConfigurationProperties({SystemSettings.class, AppConfig.class})
public class SpringaiDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SpringaiDemoApplication.class, args);
}
}到此這篇關(guān)于基于SpringAI+Qdrant+Ollama本地模型和向量數(shù)據(jù)庫開發(fā)問答和RAG檢索(完整代碼)的文章就介紹到這了,更多相關(guān)SpringAI 向量數(shù)據(jù)庫開發(fā)問答和RAG檢索內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
SpringBoot實現(xiàn)mysql與clickhouse多數(shù)據(jù)源的項目實踐
本文主要介紹了SpringBoot實現(xiàn)mysql與clickhouse多數(shù)據(jù)源的項目實踐,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-11-11
PowerJob的QueryConvertUtils工作流程源碼解讀
這篇文章主要為大家介紹了PowerJob的QueryConvertUtils工作流程源碼解讀,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2024-01-01
關(guān)于springboot響應(yīng)式編程整合webFlux的問題
在springboot2.x版本中提供了webFlux依賴模塊,該模塊有兩種模型實現(xiàn):一種是基于功能性端點的方式,另一種是基于SpringMVC注解方式,今天通過本文給大家介紹springboot響應(yīng)式編程整合webFlux的問題,感興趣的朋友一起看看吧2022-01-01
java+mysql模擬實現(xiàn)銀行系統(tǒng)
這篇文章主要為大家詳細(xì)介紹了java+mysql模擬實現(xiàn)銀行系統(tǒng),文中示例代碼介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們可以參考一下2020-05-05

