refactor(ai-chat): 将会话ID类型统一由UUID改为字符串类型

- 修改AiChat相关实体、VO及Mapper中sessionId字段类型为String
- 调整AiChatController接口,支持字符串类型sessionId参数
- 修改AiChatService及实现类中相关方法的sessionId参数类型
- 更新业务逻辑中sessionId的处理,移除UUID转换操作

feat(vector-store): 添加文件访问URL字段及切片更新接口

- 在vector_store表及对应实体中新增file_url字段
- 增加AiDocument的fileUrl字段,保存文件访问链接
- 在DocumentProcessor处理切片时更新file_url字段
- 添加AiDocumentMapper中updateChunkFields接口及XML实现

feat(attachment): 知识库文件上传支持记录文件附件

- 新增FileAttachment实体及Mapper,保存上传文件元信息
- 在AiKnowledgeBaseServiceImpl实现文件上传后保存附件记录
- 上传接口返回文件URL并保存到文档和附件表中
This commit is contained in:
2026-03-30 18:35:20 +08:00
parent 4399550418
commit 06d82187ff
13 changed files with 110 additions and 34 deletions

View File

@@ -41,6 +41,7 @@ CREATE TABLE vector_store (
file_type VARCHAR(50),
file_size BIGINT,
file_path VARCHAR(500),
file_url VARCHAR(500),
-- 时间信息 (用于时间维度检索)
doc_date DATE,

View File

@@ -17,7 +17,6 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
import java.util.UUID;
/**
* AI对话控制器
@@ -157,7 +156,7 @@ public class AiChatController {
@GetMapping("/session/{sessionId}/messages")
@Operation(summary = "获取会话历史", description = "获取指定会话的所有消息")
public BaseResponse<List<ChatMessageVO>> getSessionMessages(
@PathVariable UUID sessionId) {
@PathVariable String sessionId) {
Long userId = SecurityUtils.getCurrentUserId();
if (userId == null) {
return ResultUtils.error("用户未登录");
@@ -180,7 +179,7 @@ public class AiChatController {
*/
@DeleteMapping("/session/{sessionId}")
@Operation(summary = "删除会话", description = "删除指定的对话会话")
public BaseResponse<Void> deleteSession(@PathVariable UUID sessionId) {
public BaseResponse<Void> deleteSession(@PathVariable String sessionId) {
Long userId = SecurityUtils.getCurrentUserId();
if (userId == null) {
return ResultUtils.error("用户未登录");

View File

@@ -6,7 +6,6 @@ import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* AI对话消息实体
@@ -20,9 +19,9 @@ public class AiChatMessage {
private Long id;
/**
* 会话ID
* 会话IDUUID格式字符串
*/
private UUID sessionId;
private String sessionId;
/**
* 会话标题

View File

@@ -101,6 +101,11 @@ public class AiDocument {
*/
private String filePath;
/**
* 文件访问URL
*/
private String fileUrl;
/**
* 文档日期(如日报日期、照片拍摄日期)
*/

View File

@@ -3,7 +3,6 @@ package cn.yinlihupo.domain.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* 会话信息VO
@@ -12,9 +11,9 @@ import java.util.UUID;
public class ChatSessionVO {
/**
* 会话ID
* 会话IDUUID格式字符串
*/
private UUID sessionId;
private String sessionId;
/**
* 会话标题

View File

@@ -8,7 +8,6 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.UUID;
/**
* AI对话历史Mapper
@@ -32,7 +31,7 @@ public interface AiChatHistoryMapper extends BaseMapper<AiChatMessage> {
* @param sessionId 会话ID
* @return 消息列表
*/
List<ChatMessageVO> selectSessionMessages(@Param("sessionId") UUID sessionId);
List<ChatMessageVO> selectSessionMessages(@Param("sessionId") String sessionId);
/**
* 获取会话最新消息序号
@@ -40,7 +39,7 @@ public interface AiChatHistoryMapper extends BaseMapper<AiChatMessage> {
* @param sessionId 会话ID
* @return 最大序号
*/
Integer selectMaxMessageIndex(@Param("sessionId") UUID sessionId);
Integer selectMaxMessageIndex(@Param("sessionId") String sessionId);
/**
* 获取会话消息数量
@@ -48,7 +47,7 @@ public interface AiChatHistoryMapper extends BaseMapper<AiChatMessage> {
* @param sessionId 会话ID
* @return 消息数量
*/
Integer selectMessageCount(@Param("sessionId") UUID sessionId);
Integer selectMessageCount(@Param("sessionId") String sessionId);
/**
* 获取会话最后一条消息时间
@@ -56,7 +55,7 @@ public interface AiChatHistoryMapper extends BaseMapper<AiChatMessage> {
* @param sessionId 会话ID
* @return 最后消息时间
*/
String selectLastMessageTime(@Param("sessionId") UUID sessionId);
String selectLastMessageTime(@Param("sessionId") String sessionId);
/**
* 根据sessionId删除消息
@@ -64,7 +63,7 @@ public interface AiChatHistoryMapper extends BaseMapper<AiChatMessage> {
* @param sessionId 会话ID
* @return 影响行数
*/
int deleteBySessionId(@Param("sessionId") UUID sessionId);
int deleteBySessionId(@Param("sessionId") String sessionId);
/**
* 获取会话的最近N条消息用于上下文
@@ -73,6 +72,6 @@ public interface AiChatHistoryMapper extends BaseMapper<AiChatMessage> {
* @param limit 限制条数
* @return 消息列表
*/
List<AiChatMessage> selectRecentMessages(@Param("sessionId") UUID sessionId,
List<AiChatMessage> selectRecentMessages(@Param("sessionId") String sessionId,
@Param("limit") Integer limit);
}

View File

@@ -106,4 +106,34 @@ public interface AiDocumentMapper extends BaseMapper<AiDocument> {
* @return 分片详情
*/
DocumentChunkVO selectChunkById(@Param("chunkId") String chunkId);
/**
* 更新切片的独立字段
*
* @param chunkId 切片ID
* @param parentId 父文档ID
* @param chunkIndex 分块序号
* @param chunkTotal 总分块数
* @param projectId 项目ID
* @param kbId 知识库ID
* @param sourceType 来源类型
* @param sourceId 来源记录ID
* @param title 文档标题
* @param docType 文档类型
* @param fileType 文件类型
* @param fileUrl 文件URL
* @return 影响行数
*/
int updateChunkFields(@Param("chunkId") String chunkId,
@Param("parentId") String parentId,
@Param("chunkIndex") Integer chunkIndex,
@Param("chunkTotal") Integer chunkTotal,
@Param("projectId") Long projectId,
@Param("kbId") Long kbId,
@Param("sourceType") String sourceType,
@Param("sourceId") Long sourceId,
@Param("title") String title,
@Param("docType") String docType,
@Param("fileType") String fileType,
@Param("fileUrl") String fileUrl);
}

View File

@@ -6,7 +6,6 @@ import cn.yinlihupo.domain.vo.ChatSessionVO;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
import java.util.UUID;
/**
* AI对话服务接口
@@ -51,7 +50,7 @@ public interface AiChatService {
* @param userId 用户ID
* @return 消息列表
*/
List<ChatMessageVO> getSessionMessages(UUID sessionId, Long userId);
List<ChatMessageVO> getSessionMessages(String sessionId, Long userId);
/**
* 删除会话
@@ -59,7 +58,7 @@ public interface AiChatService {
* @param sessionId 会话ID
* @param userId 用户ID
*/
void deleteSession(UUID sessionId, Long userId);
void deleteSession(String sessionId, Long userId);
/**
* 验证用户是否有权限访问会话
@@ -68,5 +67,5 @@ public interface AiChatService {
* @param userId 用户ID
* @return 是否有权限
*/
boolean hasSessionAccess(UUID sessionId, Long userId);
boolean hasSessionAccess(String sessionId, Long userId);
}

View File

@@ -78,7 +78,7 @@ public class AiChatServiceImpl implements AiChatService {
createSession(userId, request.getProjectId(), request.getTimelineNodeId(), request.getMessage(), title);
} else {
// 验证会话权限
if (!hasSessionAccess(UUID.fromString(sessionId), userId)) {
if (!hasSessionAccess(sessionId, userId)) {
sendError(emitter, "无权访问该会话");
return;
}
@@ -88,7 +88,7 @@ public class AiChatServiceImpl implements AiChatService {
// 发送开始消息
sendEvent(emitter, "start", Map.of(
"sessionId", finalSessionId.toString(),
"sessionId", finalSessionId,
"isNewSession", isNewSession
));
@@ -107,7 +107,7 @@ public class AiChatServiceImpl implements AiChatService {
// 4. 构建Prompt
String systemPrompt = buildSystemPrompt(request.getProjectId(), retrievedDocs);
List<Message> messages = buildMessages(UUID.fromString(finalSessionId) , request.getContextWindow(),
List<Message> messages = buildMessages(finalSessionId , request.getContextWindow(),
systemPrompt, request.getMessage());
// 5. 流式调用LLM
@@ -164,6 +164,7 @@ public class AiChatServiceImpl implements AiChatService {
public ChatSessionVO createSession(Long userId, Long projectId, Long timelineNodeId,
String firstMessage, String customTitle) {
UUID sessionId = UUID.randomUUID();
String sessionIdStr = sessionId.toString();
String title = customTitle;
if (title == null || title.isEmpty()) {
@@ -172,7 +173,7 @@ public class AiChatServiceImpl implements AiChatService {
// 保存系统消息(作为会话创建标记)
AiChatMessage message = new AiChatMessage();
message.setSessionId(sessionId);
message.setSessionId(sessionIdStr);
message.setSessionTitle(title);
message.setUserId(userId);
message.setProjectId(projectId);
@@ -185,7 +186,7 @@ public class AiChatServiceImpl implements AiChatService {
// 构建返回对象
ChatSessionVO vo = new ChatSessionVO();
vo.setSessionId(sessionId);
vo.setSessionId(sessionIdStr);
vo.setSessionTitle(title);
vo.setProjectId(projectId);
@@ -207,7 +208,7 @@ public class AiChatServiceImpl implements AiChatService {
}
@Override
public List<ChatMessageVO> getSessionMessages(UUID sessionId, Long userId) {
public List<ChatMessageVO> getSessionMessages(String sessionId, Long userId) {
// 验证权限
if (!hasSessionAccess(sessionId, userId)) {
throw new RuntimeException("无权访问该会话");
@@ -227,7 +228,7 @@ public class AiChatServiceImpl implements AiChatService {
}
@Override
public void deleteSession(UUID sessionId, Long userId) {
public void deleteSession(String sessionId, Long userId) {
// 验证权限
if (!hasSessionAccess(sessionId, userId)) {
throw new RuntimeException("无权删除该会话");
@@ -238,7 +239,7 @@ public class AiChatServiceImpl implements AiChatService {
}
@Override
public boolean hasSessionAccess(UUID sessionId, Long userId) {
public boolean hasSessionAccess(String sessionId, Long userId) {
// 查询会话的第一条消息确认归属
// 简化实现查询该session_id下是否有该用户的消息
// 实际应该查询所有消息中是否有该用户的记录
@@ -302,7 +303,7 @@ public class AiChatServiceImpl implements AiChatService {
/**
* 构建消息列表
*/
private List<Message> buildMessages(UUID sessionId, Integer contextWindow,
private List<Message> buildMessages(String sessionId, Integer contextWindow,
String systemPrompt, String currentMessage) {
List<Message> messages = new ArrayList<>();
@@ -335,11 +336,11 @@ public class AiChatServiceImpl implements AiChatService {
Long timelineNodeId, String role, String content,
String referencedDocIds) {
// 获取当前最大序号
Integer maxIndex = chatHistoryMapper.selectMaxMessageIndex(UUID.fromString(sessionId));
Integer maxIndex = chatHistoryMapper.selectMaxMessageIndex(sessionId);
int nextIndex = (maxIndex != null ? maxIndex : 0) + 1;
AiChatMessage message = new AiChatMessage();
message.setSessionId(UUID.fromString(sessionId));
message.setSessionId(sessionId);
message.setUserId(userId);
message.setProjectId(projectId);
message.setTimelineNodeId(timelineNodeId);

View File

@@ -1,9 +1,11 @@
package cn.yinlihupo.service.ai.impl;
import cn.yinlihupo.domain.entity.AiDocument;
import cn.yinlihupo.domain.entity.FileAttachment;
import cn.yinlihupo.domain.vo.DocumentChunkVO;
import cn.yinlihupo.domain.vo.KbDocumentVO;
import cn.yinlihupo.mapper.AiDocumentMapper;
import cn.yinlihupo.mapper.FileAttachmentMapper;
import cn.yinlihupo.service.ai.AiKnowledgeBaseService;
import cn.yinlihupo.service.ai.rag.DocumentProcessor;
import cn.yinlihupo.service.oss.MinioService;
@@ -28,6 +30,7 @@ public class AiKnowledgeBaseServiceImpl implements AiKnowledgeBaseService {
private final AiDocumentMapper documentMapper;
private final DocumentProcessor documentProcessor;
private final MinioService minioService;
private final FileAttachmentMapper fileAttachmentMapper;
// 支持的文件类型
private static final List<String> SUPPORTED_TYPES = List.of(
@@ -46,15 +49,31 @@ public class AiKnowledgeBaseServiceImpl implements AiKnowledgeBaseService {
String originalFilename = file.getOriginalFilename();
String fileExtension = getFileExtension(originalFilename);
String filePath = String.format("kb/%d/%s.%s", projectId, docId, fileExtension);
String fileUrl;
try {
minioService.uploadFile(filePath, file.getInputStream(), file.getContentType());
fileUrl = minioService.uploadFile(filePath, file.getInputStream(), file.getContentType());
} catch (Exception e) {
log.error("上传文件到MinIO失败: {}", e.getMessage(), e);
throw new RuntimeException("文件上传失败: " + e.getMessage());
}
// 4. 保存文档元数据
// 4. 保存到附件表
FileAttachment attachment = new FileAttachment();
attachment.setFileName(docId + "." + fileExtension);
attachment.setOriginalName(originalFilename);
attachment.setFilePath(filePath);
attachment.setFileUrl(fileUrl);
attachment.setFileType(file.getContentType());
attachment.setFileSize(file.getSize());
attachment.setStorageType("minio");
attachment.setRelatedType("knowledge_base");
attachment.setRelatedId(projectId);
attachment.setUploaderId(userId);
fileAttachmentMapper.insert(attachment);
log.info("文件附件记录保存成功, fileName: {}", originalFilename);
// 5. 保存文档元数据到向量库
AiDocument doc = new AiDocument();
doc.setId(docId); // 设置标准UUID格式的ID
doc.setProjectId(projectId);
@@ -64,6 +83,7 @@ public class AiKnowledgeBaseServiceImpl implements AiKnowledgeBaseService {
doc.setFileType(fileExtension);
doc.setFileSize(file.getSize());
doc.setFilePath(filePath);
doc.setFileUrl(fileUrl); // 保存文件URL
doc.setContent("");
doc.setStatus("pending"); // 待处理状态
doc.setChunkTotal(0);

View File

@@ -193,6 +193,7 @@ public class DocumentProcessor {
metadata.put("title", doc.getTitle() != null ? doc.getTitle() : "");
metadata.put("doc_type", doc.getDocType() != null ? doc.getDocType() : "");
metadata.put("file_type", doc.getFileType() != null ? doc.getFileType() : "");
metadata.put("file_url", doc.getFileUrl() != null ? doc.getFileUrl() : ""); // 文件访问URL
// 分片信息
metadata.put("chunk_index", i);
metadata.put("chunk_total", chunks.size());
@@ -204,6 +205,11 @@ public class DocumentProcessor {
// 存储到向量库
vectorStore.add(List.of(vectorDoc));
// 更新切片的独立字段用于直接查询避免依赖metadata
documentMapper.updateChunkFields(chunkId, docId, i, chunks.size(),
doc.getProjectId(), doc.getKbId(), doc.getSourceType(), doc.getSourceId(),
doc.getTitle(), doc.getDocType(), doc.getFileType(), doc.getFileUrl());
log.debug("存储切片: {}/{}, docId: {}, chunkId: {}", i + 1, chunks.size(), docId, chunkId);
}

View File

@@ -150,7 +150,7 @@ public class RagRetriever {
* @param limit 限制条数
* @return 历史消息列表
*/
public List<AiChatMessage> getChatHistory(UUID sessionId, int limit) {
public List<AiChatMessage> getChatHistory(String sessionId, int limit) {
try {
return chatHistoryMapper.selectRecentMessages(sessionId, limit);
} catch (Exception e) {

View File

@@ -134,4 +134,22 @@
LIMIT 1
</select>
<!-- 更新切片的独立字段 -->
<update id="updateChunkFields">
UPDATE vector_store
SET chunk_parent_id = #{parentId},
chunk_index = #{chunkIndex},
chunk_total = #{chunkTotal},
project_id = #{projectId},
kb_id = #{kbId},
source_type = #{sourceType},
source_id = #{sourceId},
title = #{title},
doc_type = #{docType},
file_type = #{fileType},
file_url = #{fileUrl},
update_time = NOW()
WHERE id = #{chunkId}
</update>
</mapper>