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_type VARCHAR(50),
file_size BIGINT, file_size BIGINT,
file_path VARCHAR(500), file_path VARCHAR(500),
file_url VARCHAR(500),
-- 时间信息 (用于时间维度检索) -- 时间信息 (用于时间维度检索)
doc_date DATE, 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 org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* AI对话控制器 * AI对话控制器
@@ -157,7 +156,7 @@ public class AiChatController {
@GetMapping("/session/{sessionId}/messages") @GetMapping("/session/{sessionId}/messages")
@Operation(summary = "获取会话历史", description = "获取指定会话的所有消息") @Operation(summary = "获取会话历史", description = "获取指定会话的所有消息")
public BaseResponse<List<ChatMessageVO>> getSessionMessages( public BaseResponse<List<ChatMessageVO>> getSessionMessages(
@PathVariable UUID sessionId) { @PathVariable String sessionId) {
Long userId = SecurityUtils.getCurrentUserId(); Long userId = SecurityUtils.getCurrentUserId();
if (userId == null) { if (userId == null) {
return ResultUtils.error("用户未登录"); return ResultUtils.error("用户未登录");
@@ -180,7 +179,7 @@ public class AiChatController {
*/ */
@DeleteMapping("/session/{sessionId}") @DeleteMapping("/session/{sessionId}")
@Operation(summary = "删除会话", description = "删除指定的对话会话") @Operation(summary = "删除会话", description = "删除指定的对话会话")
public BaseResponse<Void> deleteSession(@PathVariable UUID sessionId) { public BaseResponse<Void> deleteSession(@PathVariable String sessionId) {
Long userId = SecurityUtils.getCurrentUserId(); Long userId = SecurityUtils.getCurrentUserId();
if (userId == null) { if (userId == null) {
return ResultUtils.error("用户未登录"); return ResultUtils.error("用户未登录");

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ package cn.yinlihupo.domain.vo;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* 会话信息VO * 会话信息VO
@@ -12,9 +11,9 @@ import java.util.UUID;
public class ChatSessionVO { 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 org.apache.ibatis.annotations.Param;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* AI对话历史Mapper * AI对话历史Mapper
@@ -32,7 +31,7 @@ public interface AiChatHistoryMapper extends BaseMapper<AiChatMessage> {
* @param sessionId 会话ID * @param sessionId 会话ID
* @return 消息列表 * @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 * @param sessionId 会话ID
* @return 最大序号 * @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 * @param sessionId 会话ID
* @return 消息数量 * @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 * @param sessionId 会话ID
* @return 最后消息时间 * @return 最后消息时间
*/ */
String selectLastMessageTime(@Param("sessionId") UUID sessionId); String selectLastMessageTime(@Param("sessionId") String sessionId);
/** /**
* 根据sessionId删除消息 * 根据sessionId删除消息
@@ -64,7 +63,7 @@ public interface AiChatHistoryMapper extends BaseMapper<AiChatMessage> {
* @param sessionId 会话ID * @param sessionId 会话ID
* @return 影响行数 * @return 影响行数
*/ */
int deleteBySessionId(@Param("sessionId") UUID sessionId); int deleteBySessionId(@Param("sessionId") String sessionId);
/** /**
* 获取会话的最近N条消息用于上下文 * 获取会话的最近N条消息用于上下文
@@ -73,6 +72,6 @@ public interface AiChatHistoryMapper extends BaseMapper<AiChatMessage> {
* @param limit 限制条数 * @param limit 限制条数
* @return 消息列表 * @return 消息列表
*/ */
List<AiChatMessage> selectRecentMessages(@Param("sessionId") UUID sessionId, List<AiChatMessage> selectRecentMessages(@Param("sessionId") String sessionId,
@Param("limit") Integer limit); @Param("limit") Integer limit);
} }

View File

@@ -106,4 +106,34 @@ public interface AiDocumentMapper extends BaseMapper<AiDocument> {
* @return 分片详情 * @return 分片详情
*/ */
DocumentChunkVO selectChunkById(@Param("chunkId") String chunkId); 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 org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* AI对话服务接口 * AI对话服务接口
@@ -51,7 +50,7 @@ public interface AiChatService {
* @param userId 用户ID * @param userId 用户ID
* @return 消息列表 * @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 sessionId 会话ID
* @param userId 用户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 * @param userId 用户ID
* @return 是否有权限 * @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); createSession(userId, request.getProjectId(), request.getTimelineNodeId(), request.getMessage(), title);
} else { } else {
// 验证会话权限 // 验证会话权限
if (!hasSessionAccess(UUID.fromString(sessionId), userId)) { if (!hasSessionAccess(sessionId, userId)) {
sendError(emitter, "无权访问该会话"); sendError(emitter, "无权访问该会话");
return; return;
} }
@@ -88,7 +88,7 @@ public class AiChatServiceImpl implements AiChatService {
// 发送开始消息 // 发送开始消息
sendEvent(emitter, "start", Map.of( sendEvent(emitter, "start", Map.of(
"sessionId", finalSessionId.toString(), "sessionId", finalSessionId,
"isNewSession", isNewSession "isNewSession", isNewSession
)); ));
@@ -107,7 +107,7 @@ public class AiChatServiceImpl implements AiChatService {
// 4. 构建Prompt // 4. 构建Prompt
String systemPrompt = buildSystemPrompt(request.getProjectId(), retrievedDocs); 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()); systemPrompt, request.getMessage());
// 5. 流式调用LLM // 5. 流式调用LLM
@@ -164,6 +164,7 @@ public class AiChatServiceImpl implements AiChatService {
public ChatSessionVO createSession(Long userId, Long projectId, Long timelineNodeId, public ChatSessionVO createSession(Long userId, Long projectId, Long timelineNodeId,
String firstMessage, String customTitle) { String firstMessage, String customTitle) {
UUID sessionId = UUID.randomUUID(); UUID sessionId = UUID.randomUUID();
String sessionIdStr = sessionId.toString();
String title = customTitle; String title = customTitle;
if (title == null || title.isEmpty()) { if (title == null || title.isEmpty()) {
@@ -172,7 +173,7 @@ public class AiChatServiceImpl implements AiChatService {
// 保存系统消息(作为会话创建标记) // 保存系统消息(作为会话创建标记)
AiChatMessage message = new AiChatMessage(); AiChatMessage message = new AiChatMessage();
message.setSessionId(sessionId); message.setSessionId(sessionIdStr);
message.setSessionTitle(title); message.setSessionTitle(title);
message.setUserId(userId); message.setUserId(userId);
message.setProjectId(projectId); message.setProjectId(projectId);
@@ -185,7 +186,7 @@ public class AiChatServiceImpl implements AiChatService {
// 构建返回对象 // 构建返回对象
ChatSessionVO vo = new ChatSessionVO(); ChatSessionVO vo = new ChatSessionVO();
vo.setSessionId(sessionId); vo.setSessionId(sessionIdStr);
vo.setSessionTitle(title); vo.setSessionTitle(title);
vo.setProjectId(projectId); vo.setProjectId(projectId);
@@ -207,7 +208,7 @@ public class AiChatServiceImpl implements AiChatService {
} }
@Override @Override
public List<ChatMessageVO> getSessionMessages(UUID sessionId, Long userId) { public List<ChatMessageVO> getSessionMessages(String sessionId, Long userId) {
// 验证权限 // 验证权限
if (!hasSessionAccess(sessionId, userId)) { if (!hasSessionAccess(sessionId, userId)) {
throw new RuntimeException("无权访问该会话"); throw new RuntimeException("无权访问该会话");
@@ -227,7 +228,7 @@ public class AiChatServiceImpl implements AiChatService {
} }
@Override @Override
public void deleteSession(UUID sessionId, Long userId) { public void deleteSession(String sessionId, Long userId) {
// 验证权限 // 验证权限
if (!hasSessionAccess(sessionId, userId)) { if (!hasSessionAccess(sessionId, userId)) {
throw new RuntimeException("无权删除该会话"); throw new RuntimeException("无权删除该会话");
@@ -238,7 +239,7 @@ public class AiChatServiceImpl implements AiChatService {
} }
@Override @Override
public boolean hasSessionAccess(UUID sessionId, Long userId) { public boolean hasSessionAccess(String sessionId, Long userId) {
// 查询会话的第一条消息确认归属 // 查询会话的第一条消息确认归属
// 简化实现查询该session_id下是否有该用户的消息 // 简化实现查询该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) { String systemPrompt, String currentMessage) {
List<Message> messages = new ArrayList<>(); List<Message> messages = new ArrayList<>();
@@ -335,11 +336,11 @@ public class AiChatServiceImpl implements AiChatService {
Long timelineNodeId, String role, String content, Long timelineNodeId, String role, String content,
String referencedDocIds) { String referencedDocIds) {
// 获取当前最大序号 // 获取当前最大序号
Integer maxIndex = chatHistoryMapper.selectMaxMessageIndex(UUID.fromString(sessionId)); Integer maxIndex = chatHistoryMapper.selectMaxMessageIndex(sessionId);
int nextIndex = (maxIndex != null ? maxIndex : 0) + 1; int nextIndex = (maxIndex != null ? maxIndex : 0) + 1;
AiChatMessage message = new AiChatMessage(); AiChatMessage message = new AiChatMessage();
message.setSessionId(UUID.fromString(sessionId)); message.setSessionId(sessionId);
message.setUserId(userId); message.setUserId(userId);
message.setProjectId(projectId); message.setProjectId(projectId);
message.setTimelineNodeId(timelineNodeId); message.setTimelineNodeId(timelineNodeId);

View File

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

View File

@@ -193,6 +193,7 @@ public class DocumentProcessor {
metadata.put("title", doc.getTitle() != null ? doc.getTitle() : ""); metadata.put("title", doc.getTitle() != null ? doc.getTitle() : "");
metadata.put("doc_type", doc.getDocType() != null ? doc.getDocType() : ""); metadata.put("doc_type", doc.getDocType() != null ? doc.getDocType() : "");
metadata.put("file_type", doc.getFileType() != null ? doc.getFileType() : ""); 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_index", i);
metadata.put("chunk_total", chunks.size()); metadata.put("chunk_total", chunks.size());
@@ -204,6 +205,11 @@ public class DocumentProcessor {
// 存储到向量库 // 存储到向量库
vectorStore.add(List.of(vectorDoc)); 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); log.debug("存储切片: {}/{}, docId: {}, chunkId: {}", i + 1, chunks.size(), docId, chunkId);
} }

View File

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

View File

@@ -134,4 +134,22 @@
LIMIT 1 LIMIT 1
</select> </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> </mapper>