2991692032 3 weeks ago
parent 65855dbb88
commit 9e0200a0e3

@ -3,7 +3,7 @@ import axios from 'axios'
// 创建axios实例 // 创建axios实例
const api = axios.create({ const api = axios.create({
baseURL: 'http://localhost:8087', baseURL: 'http://localhost:8087',
timeout: 10000, timeout: 30000,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }

@ -39,7 +39,8 @@ export const uploadResource = (data: {
}>>('/resources', formData, { }>>('/resources', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data' 'Content-Type': 'multipart/form-data'
} },
timeout: 60000 // 文件上传60秒超时
}) })
} }

@ -3,7 +3,7 @@ import axios from 'axios'
// 创建axios实例 // 创建axios实例
const request = axios.create({ const request = axios.create({
baseURL: 'http://localhost:8087', baseURL: 'http://localhost:8087',
timeout: 10000, timeout: 30000,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }

@ -8,7 +8,7 @@
<div class="resource-detail-content" v-loading="loading"> <div class="resource-detail-content" v-loading="loading">
<!-- 返回按钮 --> <!-- 返回按钮 -->
<div class="back-section"> <div class="back-section">
<el-button @click="goBack" type="primary" text> <el-button @click="goBack" text class="back-button">
<el-icon><ArrowLeft /></el-icon> <el-icon><ArrowLeft /></el-icon>
返回资源列表 返回资源列表
</el-button> </el-button>
@ -65,6 +65,17 @@
{{ resource.isLiked ? '已点赞' : '点赞' }} {{ resource.isLiked ? '已点赞' : '点赞' }}
({{ resource.likeCount }}) ({{ resource.likeCount }})
</el-button> </el-button>
<!-- 如果是当前用户上传的资源显示删除按钮 -->
<el-button
v-if="isOwner"
type="danger"
size="large"
@click="confirmDeleteResource"
:loading="deleting"
>
<el-icon><Delete /></el-icon>
删除资源
</el-button>
</div> </div>
</div> </div>
@ -137,9 +148,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { import {
ArrowLeft, ArrowLeft,
Document, Document,
@ -149,13 +160,15 @@ import {
User, User,
Calendar, Calendar,
Folder, Folder,
View View,
Delete
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { import {
getResourceDetail, getResourceDetail,
downloadResource as downloadResourceAPI, downloadResource as downloadResourceAPI,
likeResource, likeResource,
deleteResource,
type Resource as BaseResource type Resource as BaseResource
} from '@/api/resources' } from '@/api/resources'
import type { ApiResponse } from '@/types' import type { ApiResponse } from '@/types'
@ -175,8 +188,14 @@ const userStore = useUserStore()
const loading = ref(false) const loading = ref(false)
const downloading = ref(false) const downloading = ref(false)
const liking = ref(false) const liking = ref(false)
const deleting = ref(false)
const resource = ref<ExtendedResource | null>(null) const resource = ref<ExtendedResource | null>(null)
//
const isOwner = computed(() => {
return resource.value && userStore.user && resource.value.userId === userStore.user.id
})
// //
const goBack = () => { const goBack = () => {
router.push('/resources') router.push('/resources')
@ -271,6 +290,39 @@ const toggleLike = async () => {
} }
} }
const confirmDeleteResource = async () => {
if (!resource.value) return
try {
await ElMessageBox.confirm(
'确定要删除这个资源吗?删除后无法恢复。',
'确认删除',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
}
)
deleting.value = true
const response = await deleteResource(resource.value.id) as any as ApiResponse<null>
if (response.code === 200) {
ElMessage.success('资源删除成功')
goBack()
} else {
ElMessage.error(response.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除资源失败:', error)
ElMessage.error('删除失败')
}
} finally {
deleting.value = false
}
}
// //
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B' if (bytes === 0) return '0 B'
@ -358,6 +410,17 @@ onMounted(() => {
align-items: center; align-items: center;
} }
.back-button {
color: var(--gray-600) !important;
font-weight: 400 !important;
padding: 8px 12px !important;
}
.back-button:hover {
color: var(--primary-600) !important;
background-color: var(--gray-50) !important;
}
.resource-detail-card { .resource-detail-card {
padding: 32px; padding: 32px;
display: flex; display: flex;

@ -1,137 +0,0 @@
# Spring AI ChatMemory MySQL 集成说明
## 概述
本项目已成功集成Spring AI的ChatMemory功能使用MySQL作为持久化存储实现了真正的AI会话记忆功能。
## 主要改进
### 1. 删除的错误实现
- ❌ 删除了 `AiChatSession.java` - 自定义会话实体与Spring AI不兼容
- ❌ 删除了 `AiChatMessage.java` - 自定义消息实体与Spring AI不兼容
- ❌ 删除了基于TODO注释的伪实现
### 2. 新增的正确实现
- ✅ 添加了 `spring-ai-starter-model-chat-memory-repository-jdbc` 依赖
- ✅ 配置了基于MySQL的JdbcChatMemoryRepository
- ✅ 集成了MessageChatMemoryAdvisor实现自动会话记忆
- ✅ 使用Spring AI标准的ChatMemory接口
## 核心功能
### 会话记忆机制
- **自动记忆**: 每次对话自动保存到MySQL数据库
- **上下文保持**: 支持最多20条消息的上下文窗口
- **会话隔离**: 不同conversationId的会话完全隔离
- **持久化存储**: 重启服务后会话记忆不丢失
### 数据库表结构
```sql
CREATE TABLE SPRING_AI_CHAT_MEMORY (
conversation_id VARCHAR(36) NOT NULL, -- 会话ID
content TEXT NOT NULL, -- 消息内容
type VARCHAR(10) NOT NULL, -- 消息类型USER/ASSISTANT/SYSTEM/TOOL
timestamp TIMESTAMP NOT NULL, -- 时间戳
INDEX idx_conversation_id_timestamp (conversation_id, timestamp)
);
```
## API使用说明
### 1. 发送消息(带会话记忆)
```http
POST /ai/chat?prompt=你好&sessionId=my-session-001
```
- 自动将用户消息和AI回复保存到数据库
- 后续对话会自动加载历史上下文
### 2. 测试记忆功能
```http
GET /ai/test/memory?conversationId=test-session&message=我的名字是张三
GET /ai/test/memory?conversationId=test-session&message=我的名字是什么?
```
- 第二次询问AI会记住之前说过的名字
### 3. 查看会话历史
```http
GET /ai/test/history/test-session
```
### 4. 清空会话记忆
```http
DELETE /ai/test/memory/test-session
```
## 配置说明
### application.yml 关键配置
```yaml
spring:
ai:
chat:
memory:
repository:
jdbc:
initialize-schema: always # 自动初始化表结构
schema: classpath:schema-mysql.sql
```
### AiConfig.java 关键配置
```java
@Bean
public ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(chatMemoryRepository)
.maxMessages(20) // 保留最近20条消息
.build();
}
@Bean
public ChatClient chatClient(OpenAiChatModel model, ChatMemory chatMemory) {
return ChatClient.builder(model)
.defaultAdvisors(
new SimpleLoggerAdvisor(),
MessageChatMemoryAdvisor.builder(chatMemory).build() // 自动记忆
)
.build();
}
```
## 使用最佳实践
### 1. 会话ID管理
- 前端生成唯一的sessionId`session_${timestamp}_${random}`
- 同一会话的所有消息使用相同的sessionId
- 新建会话时生成新的sessionId
### 2. 会话记忆策略
- 系统自动保留最近20条消息作为上下文
- 超过20条消息时旧消息会被自动清理仅从内存中数据库保留完整历史
- 可根据需要调整`maxMessages`参数
### 3. 数据库维护
- 定期清理过期的会话数据
- 监控`SPRING_AI_CHAT_MEMORY`表的大小
- 考虑为长期存储添加分区策略
## 注意事项
1. **会话列表功能限制**: Spring AI ChatMemory专注于消息存储不提供会话元数据管理。如需要会话列表、标题管理等功能建议维护单独的会话管理表。
2. **性能优化**: 对于高并发场景,考虑:
- 数据库连接池配置
- 消息内容压缩
- 定期清理策略
3. **错误处理**: 当数据库连接失败时ChatMemory会降级为内存模式重启后会话记忆将丢失。
## 测试验证
启动应用后,可以通过以下步骤验证功能:
1. 访问 Swagger UI: `http://localhost:8087/doc.html`
2. 找到 "AI测试接口" 分组
3. 使用测试接口验证会话记忆功能
4. 检查MySQL数据库中的`SPRING_AI_CHAT_MEMORY`表
这样就完成了Spring AI ChatMemory与MySQL的完整集成实现了真正的AI会话记忆功能

@ -1,250 +0,0 @@
# AI聊天会话历史功能设置指南
## 概述
本文档详细说明如何在您的UniLife项目中设置和使用AI聊天会话历史功能。该功能结合了Spring AI的ChatMemory会话记忆和MySQL数据库存储实现了完整的会话历史管理。
## 功能特性
### 🔥 核心功能
- **双重存储架构**: Spring AI ChatMemory短期记忆+ MySQL长期历史
- **自动同步**: ChatMemory消息自动同步到MySQL历史表
- **会话管理**: 支持会话列表、标题管理、删除等操作
- **匿名支持**: 支持匿名会话和用户会话
- **性能优化**: 历史表查询优化,支持分页
- **兼容性**: 完全兼容现有的Spring AI ChatMemory功能
### 📊 数据库表结构
1. **SPRING_AI_CHAT_MEMORY** - Spring AI原生记忆表
2. **ai_chat_sessions** - 会话元数据管理表
3. **ai_chat_messages_history** - 消息历史详情表
## 设置步骤
### 1. 数据库初始化
运行以下SQL脚本来创建必要的表结构
```sql
-- Spring AI Chat Memory表自动创建
CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY (
conversation_id VARCHAR(36) NOT NULL COMMENT '会话ID',
content TEXT NOT NULL COMMENT '消息内容',
type VARCHAR(10) NOT NULL COMMENT '消息类型USER、ASSISTANT、SYSTEM、TOOL',
`timestamp` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '消息时间戳',
INDEX idx_conversation_id_timestamp (conversation_id, `timestamp`),
CONSTRAINT chk_message_type CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 会话历史管理表
CREATE TABLE IF NOT EXISTS ai_chat_sessions (
id VARCHAR(64) PRIMARY KEY COMMENT '会话ID前端生成',
user_id BIGINT NULL COMMENT '用户ID可选支持匿名会话',
title VARCHAR(200) NOT NULL DEFAULT '新对话' COMMENT '会话标题',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
last_message_time TIMESTAMP NULL COMMENT '最后消息时间',
message_count INT NOT NULL DEFAULT 0 COMMENT '消息总数',
INDEX idx_user_id (user_id),
INDEX idx_created_at (created_at),
INDEX idx_updated_at (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 会话消息历史详情表
CREATE TABLE IF NOT EXISTS ai_chat_messages_history (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息历史ID',
session_id VARCHAR(64) NOT NULL COMMENT '会话ID',
conversation_id VARCHAR(36) NOT NULL COMMENT 'Spring AI会话ID',
role ENUM('user', 'assistant', 'system', 'tool') NOT NULL COMMENT '消息角色',
content TEXT NOT NULL COMMENT '消息内容',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_session_id (session_id),
INDEX idx_conversation_id (conversation_id),
INDEX idx_created_at (created_at),
FOREIGN KEY (session_id) REFERENCES ai_chat_sessions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### 2. 配置验证
确保`application.yml`中包含以下配置:
```yaml
spring:
ai:
chat:
memory:
repository:
jdbc:
initialize-schema: always
schema: classpath:schema-mysql.sql
datasource:
url: jdbc:mysql://localhost:3306/UniLife?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
```
### 3. Maven依赖检查
确保`pom.xml`中包含Spring AI JDBC ChatMemory依赖
```xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
```
## 使用指南
### API接口说明
#### 1. 发送消息(带会话记忆)
```http
GET /ai/chat?prompt=你好&sessionId=session_001
```
#### 2. 获取会话列表
```http
GET /ai/sessions?page=1&size=20
```
#### 3. 获取会话消息历史
```http
GET /ai/sessions/{sessionId}/messages?page=1&size=50
```
#### 4. 创建会话
```http
POST /ai/sessions
{
"sessionId": "session_123456789",
"title": "关于Spring Boot的讨论"
}
```
#### 5. 更新会话标题
```http
PUT /ai/sessions/{sessionId}
{
"title": "新的会话标题"
}
```
#### 6. 删除会话
```http
DELETE /ai/sessions/{sessionId}
```
### 测试接口
系统提供了专门的测试接口来验证功能:
#### 1. 测试会话记忆
```http
GET /ai/test/memory?conversationId=test-001&message=我的名字是张三
GET /ai/test/memory?conversationId=test-001&message=我的名字是什么?
```
#### 2. 查看会话历史
```http
GET /ai/test/history/test-001
```
#### 3. 查看Spring AI原始记忆
```http
GET /ai/test/raw-memory/test-001
```
#### 4. 同步记忆到历史表
```http
POST /ai/test/sync/test-001
```
## 架构说明
### 数据流程
1. **用户发送消息** → ChatClient处理 → Spring AI ChatMemory自动存储
2. **消息处理完成** → 自动同步到MySQL历史表
3. **查询历史** → 优先从历史表查询(性能更好)
4. **会话管理** → 独立的会话元数据管理
### 关键组件
- **AiChatSessionHistoryService**: 会话历史管理服务
- **AiChatSessionMapper**: 会话数据访问层
- **AiChatMessageHistoryMapper**: 消息历史数据访问层
- **AiServiceImpl**: 整合Spring AI和历史管理的主服务
## 最佳实践
### 1. 会话ID管理
- 使用格式:`session_${timestamp}_${random}`
- 前端生成,确保唯一性
- 示例:`session_1703827200000_abc123`
### 2. 性能优化
- 定期清理过期会话数据
- 监控`SPRING_AI_CHAT_MEMORY`表大小
- 使用分页查询大量历史数据
### 3. 错误处理
- 数据库连接失败时ChatMemory会降级为内存模式
- 同步失败不影响主要聊天功能
- 提供手动同步接口
## 监控和维护
### 1. 数据库监控
```sql
-- 查看会话统计
SELECT COUNT(*) as total_sessions FROM ai_chat_sessions;
-- 查看消息统计
SELECT COUNT(*) as total_messages FROM ai_chat_messages_history;
-- 查看Spring AI记忆表大小
SELECT COUNT(*) as memory_records FROM SPRING_AI_CHAT_MEMORY;
```
### 2. 清理过期数据
```sql
-- 删除30天前的会话
DELETE FROM ai_chat_sessions
WHERE updated_at < DATE_SUB(NOW(), INTERVAL 30 DAY);
```
## 故障排除
### 常见问题
1. **表不存在错误**
- 检查`schema-mysql.sql`是否正确
- 确认`initialize-schema: always`配置
2. **同步失败**
- 检查数据库连接
- 查看日志中的错误信息
3. **会话列表为空**
- 确认会话已创建:`POST /ai/test/session`
- 检查数据库中`ai_chat_sessions`表
### 调试命令
```bash
# 查看应用日志
tail -f logs/unilife.log | grep -i "chat\|memory\|session"
# 检查数据库连接
mysql -u root -p -e "USE UniLife; SHOW TABLES LIKE '%chat%';"
```
## 总结
该实现提供了一个完整的AI聊天会话历史管理解决方案结合了Spring AI的强大记忆功能和MySQL的持久化存储。通过这种双重存储架构您既享受了Spring AI的智能记忆管理又获得了完整的会话历史功能。
启动应用后访问Swagger UI`http://localhost:8087/doc.html`查看所有可用的API接口并进行测试。

@ -195,6 +195,22 @@
<groupId>org.springframework.ai</groupId> <groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId> <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency> </dependency>
<!-- Spring AI Chroma Vector Store -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-chroma</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>

@ -0,0 +1,37 @@
package com.unilife.common.constant;
public class Prompt {
public static final String Prompt = """
#
AIUniLife
#
1. ****
*
2. ****
* []
* ****
3. ****
*
*
*
4. ****
* ****
*
*
*
*
* ****
*
5. ****
*
*
* 使
""";
}

@ -1,18 +1,28 @@
package com.unilife.config; package com.unilife.config;
import com.unilife.common.constant.Prompt;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.ChatMemoryRepository; import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory; import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.openai.OpenAiChatModel; import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration @Configuration
@EnableAsync
public class AiConfig { public class AiConfig {
/** /**
@ -37,16 +47,39 @@ public class AiConfig {
.maxMessages(20) // 保留最近20条消息作为上下文 .maxMessages(20) // 保留最近20条消息作为上下文
.build(); .build();
} }
/**
* PDF
*/
@Bean("pdfVectorTaskExecutor")
public Executor pdfVectorTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2); // 核心线程数
executor.setMaxPoolSize(5); // 最大线程数
executor.setQueueCapacity(100); // 队列容量
executor.setThreadNamePrefix("pdf-vector-");
executor.setKeepAliveSeconds(60); // 线程空闲时间
executor.initialize();
return executor;
}
/** /**
* ChatClientChat Memory * ChatClientChat Memory
*/ */
@Bean @Bean
public ChatClient chatClient(OpenAiChatModel model, ChatMemory chatMemory) { public ChatClient chatClient(OpenAiChatModel model, ChatMemory chatMemory, VectorStore vectorStore) {
return ChatClient.builder(model) return ChatClient.builder(model)
.defaultSystem(Prompt.Prompt) // 设置默认系统提示
.defaultAdvisors( .defaultAdvisors(
new SimpleLoggerAdvisor(), new SimpleLoggerAdvisor(),
MessageChatMemoryAdvisor.builder(chatMemory).build() MessageChatMemoryAdvisor.builder(chatMemory).build(),
QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder()
.similarityThreshold(0.5d)
.topK(5)
.build())
.build()
) )
.build(); .build();
} }

@ -0,0 +1,112 @@
package com.unilife.service.impl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.ExtractedTextFormatter;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import com.unilife.model.entity.Resource;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* PDF
*/
@Slf4j
@Service
public class PdfVectorAsyncService {
@Autowired
private VectorStore vectorStore;
/**
* PDF
* @param fileBytes PDF
* @param originalFilename
* @param resource
*/
@Async("pdfVectorTaskExecutor")
public void processPdfVectorAsync(byte[] fileBytes, String originalFilename, Resource resource) {
try {
log.info("开始异步处理PDF向量化资源ID: {},文件: {}", resource.getId(), originalFilename);
// 创建临时文件
String tempFileName = "pdf_" + resource.getId() + "_" + System.currentTimeMillis() + ".pdf";
java.nio.file.Path tempFile = java.nio.file.Files.createTempFile(tempFileName, ".pdf");
try {
// 将字节数组写入临时文件
java.nio.file.Files.write(tempFile, fileBytes);
// 创建PDF文档读取器
PagePdfDocumentReader reader = new PagePdfDocumentReader(
tempFile.toUri().toString(),
PdfDocumentReaderConfig.builder()
.withPageExtractedTextFormatter(ExtractedTextFormatter.defaults())
.withPagesPerDocument(1)
.build()
);
// 读取文档
List<Document> documents = reader.read();
// 为每个文档添加资源ID元数据方便后续删除
for (Document document : documents) {
Map<String, Object> metadata = new HashMap<>(document.getMetadata());
metadata.put("resourceId", resource.getId().toString());
metadata.put("title", resource.getTitle());
// 创建新的文档对象包含资源ID元数据
Document enrichedDocument = new Document(document.getText(), metadata);
documents.set(documents.indexOf(document), enrichedDocument);
}
// 存储到向量数据库
vectorStore.add(documents);
log.info("PDF向量化处理完成资源ID: {},处理文档数: {}",
resource.getId(), documents.size());
} finally {
// 清理临时文件
try {
java.nio.file.Files.deleteIfExists(tempFile);
} catch (Exception e) {
log.warn("清理临时文件失败: {}", tempFile, e);
}
}
} catch (Exception e) {
log.error("PDF异步向量化处理失败资源ID: {}", resource.getId(), e);
}
}
/**
*
* @param resourceId ID
* @param resourceTitle
*/
@Async("pdfVectorTaskExecutor")
public void deleteVectorDocumentsAsync(Long resourceId, String resourceTitle) {
try {
log.info("开始删除资源ID: {} 的向量文档,标题: {}", resourceId, resourceTitle);
// 使用Spring AI标准的过滤删除方法
String filterExpression = "resourceId == '" + resourceId.toString() + "'";
vectorStore.delete(filterExpression);
log.info("✅ 资源ID: {} 的向量文档删除成功", resourceId);
} catch (Exception e) {
log.error("❌ 删除资源ID: {} 的向量文档失败", resourceId, e);
// 根据业务需求决定是否重新抛出异常
// throw new VectorOperationException("向量文档删除失败", e);
}
}
}

@ -15,23 +15,17 @@ import com.unilife.model.vo.ResourceVO;
import com.unilife.service.ResourceService; import com.unilife.service.ResourceService;
import com.unilife.utils.OssService; import com.unilife.utils.OssService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException; import java.util.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Slf4j @Slf4j
@Service @Service
public class ResourceServiceImpl implements ResourceService { public class ResourceServiceImpl implements ResourceService {
@ -51,6 +45,11 @@ public class ResourceServiceImpl implements ResourceService {
@Autowired @Autowired
private ResourceLikeMapper resourceLikeMapper; private ResourceLikeMapper resourceLikeMapper;
@Autowired
private PdfVectorAsyncService pdfVectorAsyncService;
// 文件存储路径实际项目中应该配置在application.yml中 // 文件存储路径实际项目中应该配置在application.yml中
private static final String UPLOAD_DIR = "uploads/resources/"; private static final String UPLOAD_DIR = "uploads/resources/";
// OSS存储目录 // OSS存储目录
@ -96,6 +95,18 @@ public class ResourceServiceImpl implements ResourceService {
// 保存资源记录 // 保存资源记录
resourceMapper.insert(resource); resourceMapper.insert(resource);
// 异步处理PDF文件的向量存储
if ("application/pdf".equals(file.getContentType())) {
try {
// 先读取文件内容到字节数组,避免异步处理时临时文件被删除
byte[] fileBytes = file.getBytes();
pdfVectorAsyncService.processPdfVectorAsync(fileBytes, file.getOriginalFilename(), resource);
log.info("PDF文件已提交异步向量化处理资源ID: {}", resource.getId());
} catch (Exception e) {
log.error("读取PDF文件内容失败跳过向量化处理资源ID: {}", resource.getId(), e);
}
}
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("resourceId", resource.getId()); data.put("resourceId", resource.getId());
@ -236,6 +247,13 @@ public class ResourceServiceImpl implements ResourceService {
return Result.error(403, "无权限删除此资源"); return Result.error(403, "无权限删除此资源");
} }
// 先启动异步删除向量库中的相关文档仅针对PDF文件
// 在删除数据库记录之前启动,确保异步方法能获取到资源信息
if ("application/pdf".equals(resource.getFileType())) {
pdfVectorAsyncService.deleteVectorDocumentsAsync(resourceId, resource.getTitle());
log.info("PDF文件已提交异步删除向量文档资源ID: {}", resourceId);
}
// 删除OSS中的文件 // 删除OSS中的文件
try { try {
String fileUrl = resource.getFileUrl(); String fileUrl = resource.getFileUrl();
@ -247,7 +265,7 @@ public class ResourceServiceImpl implements ResourceService {
// 继续执行,不影响数据库记录的删除 // 继续执行,不影响数据库记录的删除
} }
// 删除资源(逻辑删除) // 最后删除资源(逻辑删除)
resourceMapper.delete(resourceId); resourceMapper.delete(resourceId);
return Result.success(null, "删除成功"); return Result.success(null, "删除成功");
@ -361,4 +379,7 @@ public class ResourceServiceImpl implements ResourceService {
return Result.success(data); return Result.success(data);
} }
} }

@ -1,10 +1,17 @@
server: server:
port: 8087 port: 8087
# 超时配置
tomcat:
connection-timeout: 60000 # 连接超时60秒
threads:
max: 100 # 最大线程数
spring: spring:
main:
allow-bean-definition-overriding: true # 允许Bean定义覆盖
ai: ai:
openai: openai:
base-url: https://dashscope.aliyuncs.com/compatible-mode base-url: https://dashscope.aliyuncs.com/compatible-mode
api-key: ${OPENAI_API_KEY:} api-key: ${OPENAI_API_KEY:sk-temp-key-for-testing}
chat: chat:
options: options:
model: qwen-max-latest model: qwen-max-latest
@ -18,6 +25,14 @@ spring:
jdbc: jdbc:
initialize-schema: always # 自动初始化表结构 initialize-schema: always # 自动初始化表结构
schema: classpath:schema-mysql.sql schema: classpath:schema-mysql.sql
# Chroma向量存储配置
vectorstore:
chroma:
client:
host: http://localhost
port: 8000
collection-name: unilife_collection
initialize-schema: true
datasource: datasource:
url: jdbc:mysql://localhost:3306/UniLife?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8 url: jdbc:mysql://localhost:3306/UniLife?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8
username: root username: root
@ -37,10 +52,26 @@ spring:
socketFactory: socketFactory:
port: 465 port: 465
class: javax.net.ssl.SSLSocketFactory class: javax.net.ssl.SSLSocketFactory
data: # Redis基本配置用于缓存等功能
redis: data:
port: 6379 redis:
host: 127.0.0.1 port: 6379
host: 127.0.0.1
database: 0
timeout: 10000ms
lettuce:
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB
session:
timeout: 30m # 会话超时30分钟
knife4j: knife4j:
enable: true enable: true
openapi: openapi:

Loading…
Cancel
Save