diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index 7bc388f..e1ffc95 100644 --- a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java +++ b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java @@ -58,6 +58,7 @@ public class DatabaseInitConfig { safeInit("迁移 knowledge_document.content_hash 列", this::addContentHashColumn); safeInit("迁移 knowledge_document.enabled 列", this::addDocumentEnabledColumn); safeInit("迁移 knowledge_document.extra_config 列", this::addDocumentExtraConfigColumn); + safeInit("迁移 knowledge_document.file_path 列", this::addDocumentFilePathColumn); safeInit("创建客服角色表 customer_service_role", () -> { if (!checkTableExists("customer_service_role")) { @@ -307,6 +308,7 @@ public class DatabaseInitConfig { source_name VARCHAR(500), file_type VARCHAR(20) NOT NULL, file_size BIGINT DEFAULT 0 NOT NULL, + file_path VARCHAR(500), content TEXT, category_id BIGINT DEFAULT 0 NOT NULL, tags JSONB DEFAULT '{}' NOT NULL, @@ -608,6 +610,22 @@ public class DatabaseInitConfig { } } + /** + * 为 knowledge_document 表添加 file_path 列(存储原始文件路径) + */ + private void addDocumentFilePathColumn() { + try { + String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'knowledge_document' AND column_name = 'file_path'"; + Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); + if (count != null && count == 0) { + log.info("添加 knowledge_document.file_path 列"); + jdbcTemplate.execute("ALTER TABLE knowledge_document ADD COLUMN file_path VARCHAR(500)"); + } + } catch (Exception e) { + log.error("添加 knowledge_document.file_path 列失败,请手动执行: ALTER TABLE knowledge_document ADD COLUMN file_path VARCHAR(500)", e); + } + } + // ==================== P0-004: 内容安全过滤 ==================== private void createSensitiveWordTable() { @@ -1104,6 +1122,7 @@ public class DatabaseInitConfig { executeComment("COLUMN knowledge_document.content_hash", "内容 SHA-256 哈希值(用于文档去重)"); executeComment("COLUMN knowledge_document.enabled", "是否启用: TRUE=参与RAG检索, FALSE=禁用(不参与检索但保留数据)"); executeComment("COLUMN knowledge_document.extra_config", "分块参数配置(JSONB),存储 per-doc 的 chunkSize/overlap 等"); + executeComment("COLUMN knowledge_document.file_path", "原始文件存储路径(相对于存储根目录的相对路径)"); executeComment("COLUMN knowledge_document.create_time", "创建时间"); executeComment("COLUMN knowledge_document.update_time", "更新时间"); executeComment("COLUMN knowledge_document.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); diff --git a/src/main/java/com/wok/supportbot/config/FileStorageConfig.java b/src/main/java/com/wok/supportbot/config/FileStorageConfig.java new file mode 100644 index 0000000..b83dac7 --- /dev/null +++ b/src/main/java/com/wok/supportbot/config/FileStorageConfig.java @@ -0,0 +1,80 @@ +package com.wok.supportbot.config; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * 文件存储配置 + * 管理上传文件的本地存储路径 + */ +@Component +@Slf4j +public class FileStorageConfig { + + @Value("${knowledge.storage.local-path:./uploads}") + private String localPath; + + private Path storageRootPath; + + @PostConstruct + public void init() { + storageRootPath = Paths.get(localPath).toAbsolutePath().normalize(); + File rootDir = storageRootPath.toFile(); + if (!rootDir.exists()) { + boolean created = rootDir.mkdirs(); + if (created) { + log.info("创建文件存储根目录: {}", storageRootPath); + } else { + log.warn("创建文件存储根目录失败: {}", storageRootPath); + } + } + log.info("文件存储路径: {}", storageRootPath); + } + + /** + * 获取存储根路径 + */ + public Path getStorageRootPath() { + return storageRootPath; + } + + /** + * 获取指定文件的完整路径 + * + * @param relativePath 相对路径(如 "2024/01/xxx.docx") + */ + public Path getFilePath(String relativePath) { + return storageRootPath.resolve(relativePath).normalize(); + } + + /** + * 生成按年月组织的相对路径 + * + * @param originalFilename 原始文件名 + * @return 相对路径,如 "2024/01/uuid.docx" + */ + public String generateRelativePath(String originalFilename) { + String extension = getExtension(originalFilename); + String uniqueName = java.util.UUID.randomUUID().toString() + extension; + + // 按年月分目录 + java.time.LocalDate now = java.time.LocalDate.now(); + return String.format("%d/%02d/%s", now.getYear(), now.getMonthValue(), uniqueName); + } + + /** + * 获取文件扩展名(含点号) + */ + private String getExtension(String filename) { + if (filename == null || !filename.contains(".")) { + return ""; + } + return filename.substring(filename.lastIndexOf(".")).toLowerCase(); + } +} diff --git a/src/main/java/com/wok/supportbot/controller/DocumentController.java b/src/main/java/com/wok/supportbot/controller/DocumentController.java index f20c301..96bba92 100644 --- a/src/main/java/com/wok/supportbot/controller/DocumentController.java +++ b/src/main/java/com/wok/supportbot/controller/DocumentController.java @@ -250,6 +250,42 @@ public class DocumentController { // ==================== 文档管理 ==================== + /** + * 下载文档的原始文件 + * + * @param id 文档ID + */ + @GetMapping("/document/download/{id}") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity downloadDocument(@PathVariable("id") Long id) { + try { + var entry = documentService.getDocumentFile(id); + java.io.File file = entry.getKey(); + String originalFilename = entry.getValue(); + + // 使用 RFC 5987 编码中文文件名,兼容所有浏览器 + String encodedFilename = java.net.URLEncoder.encode( + originalFilename, java.nio.charset.StandardCharsets.UTF_8) + .replace("+", "%20"); + + // 设置响应头 + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + headers.set(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + encodedFilename + "\"; filename*=UTF-8''" + encodedFilename); + headers.setContentLength(file.length()); + + return ResponseEntity.ok() + .headers(headers) + .body(new org.springframework.core.io.FileSystemResource(file)); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "下载失败:" + e.getMessage() + )); + } + } + /** * 查询文档列表(分页 + 过滤 + 关键词搜索 + 标签筛选) * diff --git a/src/main/java/com/wok/supportbot/document/extract/JsonDocumentLoader.java b/src/main/java/com/wok/supportbot/document/extract/JsonDocumentLoader.java index 3fcdcd7..a9712aa 100644 --- a/src/main/java/com/wok/supportbot/document/extract/JsonDocumentLoader.java +++ b/src/main/java/com/wok/supportbot/document/extract/JsonDocumentLoader.java @@ -16,6 +16,37 @@ import java.util.List; @Slf4j public class JsonDocumentLoader { + // ==================== 从已保存的 File 加载 ==================== + + /** + * 基本读取方式(从文件) + */ + public List loadBasicJsonFromFile(File file) { + Resource resource = new FileSystemResource(file); + JsonReader reader = new JsonReader(resource); + return reader.get(); + } + + /** + * 指定字段读取方式(从文件) + */ + public List loadJsonByFieldsFromFile(File file, String... fields) { + Resource resource = new FileSystemResource(file); + JsonReader reader = new JsonReader(resource, fields); + return reader.get(); + } + + /** + * 使用 JSON Pointer 提取数组路径内容(从文件) + */ + public List loadJsonByPointerFromFile(File file, String pointer) { + Resource resource = new FileSystemResource(file); + JsonReader reader = new JsonReader(resource); + return reader.get(pointer); + } + + // ==================== 从 MultipartFile 加载(兼容旧接口) ==================== + /** * 基本读取方式 */ @@ -26,7 +57,7 @@ public class JsonDocumentLoader { } /** - * 指定字段读取方式(例如 description、features 字段) + * 指定字段读取方式 */ public List loadJsonByFields(MultipartFile file, String... fields) { Resource resource = toResource(file); @@ -35,7 +66,7 @@ public class JsonDocumentLoader { } /** - * 使用 JSON Pointer 提取数组路径内容(如 /items) + * 使用 JSON Pointer 提取数组路径内容 */ public List loadJsonByPointer(MultipartFile file, String pointer) { Resource resource = toResource(file); diff --git a/src/main/java/com/wok/supportbot/document/extract/MarkdownDocumentLoader.java b/src/main/java/com/wok/supportbot/document/extract/MarkdownDocumentLoader.java index e981088..707c6db 100644 --- a/src/main/java/com/wok/supportbot/document/extract/MarkdownDocumentLoader.java +++ b/src/main/java/com/wok/supportbot/document/extract/MarkdownDocumentLoader.java @@ -20,14 +20,38 @@ import java.util.List; @Slf4j public class MarkdownDocumentLoader { - public List loadMarkdownFromFile(MultipartFile file) { + /** + * 从已保存的文件加载 Markdown + */ + public List loadMarkdownFromFile(File file) { + try { + Resource resource = new FileSystemResource(file); + + MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder() + .withHorizontalRuleCreateDocument(true) + .withIncludeCodeBlock(false) + .withIncludeBlockquote(false) + .withAdditionalMetadata("filename", file.getName()) + .build(); + + MarkdownDocumentReader reader = new MarkdownDocumentReader(resource, config); + return reader.get(); + + } catch (Exception e) { + log.error("Markdown 文件解析失败", e); + throw new RuntimeException("Markdown 文件解析失败", e); + } + } + + /** + * 从 MultipartFile 加载 Markdown(兼容旧接口) + */ + public List loadMarkdownFromMultipartFile(MultipartFile file) { try { - // 将 MultipartFile 保存为临时文件 File temp = File.createTempFile("upload-", file.getOriginalFilename()); file.transferTo(temp); Resource resource = new FileSystemResource(temp); - // 配置文档解析 MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder() .withHorizontalRuleCreateDocument(true) .withIncludeCodeBlock(false) @@ -35,9 +59,12 @@ public class MarkdownDocumentLoader { .withAdditionalMetadata("filename", file.getOriginalFilename()) .build(); - // 读取文档内容 MarkdownDocumentReader reader = new MarkdownDocumentReader(resource, config); - return reader.get(); + List docs = reader.get(); + + // 清理临时文件 + temp.delete(); + return docs; } catch (IOException e) { log.error("Markdown 文件解析失败", e); diff --git a/src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java b/src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java index 7ee7d2c..d1ff34c 100644 --- a/src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java +++ b/src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java @@ -19,15 +19,34 @@ import java.util.UUID; @Slf4j public class TikaDocumentReader { - public List read(MultipartFile file) { + /** + * 从已保存的文件解析内容 + */ + public List readFromFile(File file) { try { - // MultipartFile 转 Resource - File tempFile = File.createTempFile("upload-", file.getOriginalFilename()); - file.transferTo(tempFile); - Resource resource = new FileSystemResource(tempFile); + Tika tika = new Tika(); + String text = tika.parseToString(new java.io.FileInputStream(file)); + + Document doc = Document.builder() + .id(UUID.randomUUID().toString()) + .text(text) + .build(); + + return Collections.singletonList(doc); + + } catch (IOException | TikaException e) { + log.error("Tika 文件解析失败", e); + throw new RuntimeException("Tika 文件解析失败", e); + } + } + /** + * 从 MultipartFile 解析内容(兼容旧接口) + */ + public List read(MultipartFile file) { + try { Tika tika = new Tika(); - String text = tika.parseToString(resource.getInputStream()); + String text = tika.parseToString(file.getInputStream()); Document doc = Document.builder() .id(UUID.randomUUID().toString()) @@ -41,4 +60,14 @@ public class TikaDocumentReader { throw new RuntimeException("Tika 文件解析失败", e); } } + + /** + * 获取文件扩展名(含点号),如 ".pptx"。无扩展名时返回 ".tmp" + */ + private String getExtension(String filename) { + if (filename == null || !filename.contains(".")) { + return ".tmp"; + } + return filename.substring(filename.lastIndexOf(".")); + } } diff --git a/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java b/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java index a889a93..e927c12 100644 --- a/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java +++ b/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java @@ -56,6 +56,12 @@ public class KnowledgeDocument implements Serializable { @TableField("file_size") private Long fileSize; + /** + * 原始文件存储路径(相对路径) + */ + @TableField("file_path") + private String filePath; + /** * 原文内容(截断预览) */ diff --git a/src/main/java/com/wok/supportbot/service/DocumentService.java b/src/main/java/com/wok/supportbot/service/DocumentService.java index aa0eb1b..028df7c 100644 --- a/src/main/java/com/wok/supportbot/service/DocumentService.java +++ b/src/main/java/com/wok/supportbot/service/DocumentService.java @@ -73,6 +73,9 @@ public class DocumentService { @Autowired private DocumentProcessingService documentProcessingService; + @Autowired + private com.wok.supportbot.config.FileStorageConfig fileStorageConfig; + // ==================== 文档上传 ==================== /** @@ -90,13 +93,15 @@ public class DocumentService { * @param tags 标签列表 * @param chunkSize 分块大小(可选,覆盖全局配置) * @param overlap 重叠大小(可选,覆盖全局配置) + * @param filePath 原始文件存储路径(可选,null表示无原始文件) * @return 创建完成的文档记录(status=PROCESSING) */ @Transactional(rollbackFor = Exception.class) public KnowledgeDocument uploadDocument(List documents, String title, String sourceName, String fileType, Long fileSize, String content, Long categoryId, List tags, - Integer chunkSize, Integer overlap) { + Integer chunkSize, Integer overlap, + String filePath) { // 0. 内容去重检查 String contentHash = computeContentHash(content); if (contentHash != null) { @@ -117,6 +122,7 @@ public class DocumentService { .sourceName(sourceName) .fileType(fileType) .fileSize(fileSize != null ? fileSize : 0L) + .filePath(filePath) .content(content != null && content.length() > 2000 ? content.substring(0, 2000) : content) .categoryId(categoryId != null ? categoryId : 0L) .tags(tags != null ? Map.of("tags", tags) : null) @@ -138,12 +144,19 @@ public class DocumentService { } /** - * 解析文件并上传 + * 解析文件并上传(同时保存原始文件到本地) */ public KnowledgeDocument uploadFile(MultipartFile file, String title, Long categoryId, List tags, Integer chunkSize, Integer overlap) { - List documents = tikaDocumentReader.read(file); + // 1. 先保存原始文件到本地磁盘(必须在解析之前,因为 transferTo 只能调用一次) + String relativePath = saveFileToLocal(file); + java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile(); + + // 2. 使用保存的文件进行解析(避免 MultipartFile 的临时文件问题) + List documents = tikaDocumentReader.readFromFile(savedFile); String fileType = getFileExtension(file.getOriginalFilename()); + + // 3. 创建文档记录并保存文件路径 return uploadDocument(documents, title != null ? title : file.getOriginalFilename(), file.getOriginalFilename(), @@ -151,17 +164,45 @@ public class DocumentService { file.getSize(), documents.get(0).getText(), categoryId, - tags, chunkSize, overlap); + tags, chunkSize, overlap, + relativePath); + } + + /** + * 保存上传的文件到本地磁盘 + * + * @param file 上传的文件 + * @return 相对路径 + */ + private String saveFileToLocal(MultipartFile file) { + try { + String relativePath = fileStorageConfig.generateRelativePath(file.getOriginalFilename()); + java.nio.file.Path targetPath = fileStorageConfig.getFilePath(relativePath); + + // 确保父目录存在 + java.io.File parentDir = targetPath.getParent().toFile(); + if (!parentDir.exists()) { + parentDir.mkdirs(); + } + + // 使用 transferTo 保存文件(这是唯一一次调用) + file.transferTo(targetPath.toFile()); + log.info("文件已保存到本地: {}", targetPath); + return relativePath; + } catch (java.io.IOException e) { + log.error("保存文件到本地失败", e); + throw new RuntimeException("保存文件失败: " + e.getMessage(), e); + } } /** - * 解析字符串并上传 + * 解析字符串并上传(无原始文件) */ public KnowledgeDocument uploadString(String content, String title, Long categoryId, List tags, Integer chunkSize, Integer overlap) { List documents = simpleStringDocumentReader.read(content); return uploadDocument(documents, title, title, "txt", - (long) content.length(), content, categoryId, tags, chunkSize, overlap); + (long) content.length(), content, categoryId, tags, chunkSize, overlap, null); } /** @@ -169,8 +210,14 @@ public class DocumentService { */ public KnowledgeDocument uploadMarkdown(MultipartFile file, String title, Long categoryId, List tags, Integer chunkSize, Integer overlap) { - List documents = markdownDocumentLoader.loadMarkdownFromFile(file); + // 1. 先保存原始文件 + String relativePath = saveFileToLocal(file); + java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile(); + + // 2. 使用保存的文件进行解析 + List documents = markdownDocumentLoader.loadMarkdownFromFile(savedFile); String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); + return uploadDocument(documents, title != null ? title : file.getOriginalFilename(), file.getOriginalFilename(), @@ -178,7 +225,8 @@ public class DocumentService { file.getSize(), content, categoryId, - tags, chunkSize, overlap); + tags, chunkSize, overlap, + relativePath); } /** @@ -186,8 +234,14 @@ public class DocumentService { */ public KnowledgeDocument uploadJsonBasic(MultipartFile file, String title, Long categoryId, List tags, Integer chunkSize, Integer overlap) { - List documents = jsonDocumentLoader.loadBasicJson(file); + // 1. 先保存原始文件 + String relativePath = saveFileToLocal(file); + java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile(); + + // 2. 使用保存的文件进行解析 + List documents = jsonDocumentLoader.loadBasicJsonFromFile(savedFile); String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); + return uploadDocument(documents, title != null ? title : file.getOriginalFilename(), file.getOriginalFilename(), @@ -195,7 +249,8 @@ public class DocumentService { file.getSize(), content, categoryId, - tags, chunkSize, overlap); + tags, chunkSize, overlap, + relativePath); } /** @@ -204,8 +259,14 @@ public class DocumentService { public KnowledgeDocument uploadJsonFields(MultipartFile file, List fields, String title, Long categoryId, List tags, Integer chunkSize, Integer overlap) { - List documents = jsonDocumentLoader.loadJsonByFields(file, fields.toArray(new String[0])); + // 1. 先保存原始文件 + String relativePath = saveFileToLocal(file); + java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile(); + + // 2. 使用保存的文件进行解析 + List documents = jsonDocumentLoader.loadJsonByFieldsFromFile(savedFile, fields.toArray(new String[0])); String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); + return uploadDocument(documents, title != null ? title : file.getOriginalFilename(), file.getOriginalFilename(), @@ -213,7 +274,8 @@ public class DocumentService { file.getSize(), content, categoryId, - tags, chunkSize, overlap); + tags, chunkSize, overlap, + relativePath); } /** @@ -222,8 +284,14 @@ public class DocumentService { public KnowledgeDocument uploadJsonPointer(MultipartFile file, String pointer, String title, Long categoryId, List tags, Integer chunkSize, Integer overlap) { - List documents = jsonDocumentLoader.loadJsonByPointer(file, pointer); + // 1. 先保存原始文件 + String relativePath = saveFileToLocal(file); + java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile(); + + // 2. 使用保存的文件进行解析 + List documents = jsonDocumentLoader.loadJsonByPointerFromFile(savedFile, pointer); String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); + return uploadDocument(documents, title != null ? title : file.getOriginalFilename(), file.getOriginalFilename(), @@ -231,7 +299,33 @@ public class DocumentService { file.getSize(), content, categoryId, - tags, chunkSize, overlap); + tags, chunkSize, overlap, + relativePath); + } + + // ==================== 文件下载 ==================== + + /** + * 获取文档的原始文件(用于下载) + * + * @param documentId 文档ID + * @return 文件对象和原始文件名 + */ + public java.util.Map.Entry getDocumentFile(Long documentId) { + KnowledgeDocument doc = documentMapper.selectById(documentId); + if (doc == null || doc.isDelete()) { + throw new RuntimeException("文档不存在"); + } + if (doc.getFilePath() == null || doc.getFilePath().isEmpty()) { + throw new RuntimeException("该文档无原始文件(纯文本上传)"); + } + + java.io.File file = fileStorageConfig.getFilePath(doc.getFilePath()).toFile(); + if (!file.exists()) { + throw new RuntimeException("文件不存在或已被删除"); + } + + return java.util.Map.entry(file, doc.getSourceName()); } // ==================== 文档管理 ==================== diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e5f0aaf..472c471 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -23,6 +23,11 @@ spring: knife4j: enable: true +# ==================== 文件存储路径 ==================== +knowledge: + storage: + local-path: D:/uploads + # ==================== 日志级别 ==================== logging: level: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index e4787ac..e0dbdaa 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -32,10 +32,15 @@ spring: knife4j: enable: false +# ==================== 文件存储路径 ==================== +knowledge: + storage: + local-path: /www/uploads + # ==================== 日志级别 ==================== # 生产环境收敛日志,仅保留 WARN 及以上,业务代码保留 INFO logging: level: - root: WARN + root: INFO com.wok.supportbot: INFO - org.springframework.ai: WARN + org.springframework.ai: INFO diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ec43dba..1a284aa 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -64,6 +64,10 @@ knowledge: faq: # FAQ 语义匹配阈值(0-1),低于此值自动降级到 RAG 检索 semantic-threshold: 0.85 + storage: + # 上传文件本地存储根路径(Windows: D:/uploads, Linux: /data/uploads) + # 各环境可在 application-{env}.yml 中覆盖 + local-path: ./uploads # ==================== Knife4j API 文档通用配置 ==================== # knife4j.enable 开关在各环境 yml 中配置(生产环境建议关闭) diff --git a/src/main/resources/init-database.sql b/src/main/resources/init-database.sql index 080bf31..2113d8a 100644 --- a/src/main/resources/init-database.sql +++ b/src/main/resources/init-database.sql @@ -94,6 +94,7 @@ CREATE TABLE IF NOT EXISTS knowledge_document ( source_name VARCHAR(500), file_type VARCHAR(20) NOT NULL, file_size BIGINT NOT NULL DEFAULT 0, + file_path VARCHAR(500), content TEXT, category_id BIGINT NOT NULL DEFAULT 0, tags JSONB NOT NULL DEFAULT '{}', @@ -101,6 +102,8 @@ CREATE TABLE IF NOT EXISTS knowledge_document ( status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING', error_message TEXT, content_hash VARCHAR(64), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + extra_config JSONB NOT NULL DEFAULT '{}', create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, is_delete BOOLEAN NOT NULL DEFAULT FALSE @@ -110,6 +113,7 @@ CREATE INDEX IF NOT EXISTS idx_knowledge_document_category ON knowledge_docu CREATE INDEX IF NOT EXISTS idx_knowledge_document_status ON knowledge_document (status); CREATE INDEX IF NOT EXISTS idx_knowledge_document_create_time ON knowledge_document (create_time DESC); CREATE INDEX IF NOT EXISTS idx_knowledge_document_content_hash ON knowledge_document (content_hash); +CREATE INDEX IF NOT EXISTS idx_knowledge_document_enabled ON knowledge_document (enabled); COMMENT ON TABLE knowledge_document IS '知识文档表'; COMMENT ON COLUMN knowledge_document.id IS '主键'; @@ -117,6 +121,7 @@ COMMENT ON COLUMN knowledge_document.title IS '文档标题'; COMMENT ON COLUMN knowledge_document.source_name IS '原始文件名'; COMMENT ON COLUMN knowledge_document.file_type IS '文件类型'; COMMENT ON COLUMN knowledge_document.file_size IS '文件大小(字节)'; +COMMENT ON COLUMN knowledge_document.file_path IS '原始文件存储路径(相对路径)'; COMMENT ON COLUMN knowledge_document.content IS '原文内容(截断预览)'; COMMENT ON COLUMN knowledge_document.category_id IS '所属分类ID'; COMMENT ON COLUMN knowledge_document.tags IS '标签(JSON)'; @@ -124,6 +129,8 @@ COMMENT ON COLUMN knowledge_document.chunk_count IS '分块数量'; COMMENT ON COLUMN knowledge_document.status IS '状态: PROCESSING / READY / FAILED'; COMMENT ON COLUMN knowledge_document.error_message IS '处理失败时的错误信息'; COMMENT ON COLUMN knowledge_document.content_hash IS '内容SHA-256哈希(用于去重)'; +COMMENT ON COLUMN knowledge_document.enabled IS '是否启用'; +COMMENT ON COLUMN knowledge_document.extra_config IS '扩展配置(JSON)'; COMMENT ON COLUMN knowledge_document.create_time IS '创建时间'; COMMENT ON COLUMN knowledge_document.update_time IS '更新时间'; COMMENT ON COLUMN knowledge_document.is_delete IS '逻辑删除'; diff --git a/src/main/resources/static/components/DocList.js b/src/main/resources/static/components/DocList.js index 0a8a119..442a904 100644 --- a/src/main/resources/static/components/DocList.js +++ b/src/main/resources/static/components/DocList.js @@ -9,7 +9,7 @@ */ import { ref, computed, onUnmounted } from 'vue' import { store } from '../js/store.js' -import { listDocuments, deleteDocument, reprocessDocument, batchDeleteDocuments, batchReprocessDocuments, toggleDocument, batchToggleDocuments, batchMoveDocuments } from '../js/api.js' +import { listDocuments, deleteDocument, reprocessDocument, batchDeleteDocuments, batchReprocessDocuments, toggleDocument, batchToggleDocuments, batchMoveDocuments, downloadDocument } from '../js/api.js' import { toast, formatDate, formatBytes } from '../js/utils.js' export default { @@ -106,6 +106,7 @@ export default { {{ formatDate(d.createTime) }} + @@ -265,6 +266,10 @@ export default { store.openDetail(id) } + function download(id) { + downloadDocument(id) + } + // P1-2.1: 切换单个文档启用/禁用 async function toggle(doc) { try { @@ -420,7 +425,7 @@ export default { formatSize, debouncedLoad, getDocTags, getRowStyle, load, toggleSelect, toggleSelectAll, clearSelection, viewDetail, toggle, remove, reprocess, batchRemove, batchReprocess, - batchEnable, batchDisable, batchMove, formatDate + batchEnable, batchDisable, batchMove, formatDate, download } } } diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js index 0694ba6..4d7b2ef 100644 --- a/src/main/resources/static/js/api.js +++ b/src/main/resources/static/js/api.js @@ -257,6 +257,58 @@ export function getDocumentChunks(id) { return getJSON(`/document/${id}/chunks`) } +/** + * 下载文档原始文件 + * 使用 fetch 携带 Token,通过 Blob 触发浏览器下载 + */ +export async function downloadDocument(id) { + try { + const response = await fetch(`/document/download/${id}`, { + headers: authHeaders() + }) + + if (response.status === 401) { + handleUnauthorized() + throw new Error('未登录或登录已过期') + } + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: '下载失败' })) + throw new Error(error.message || `HTTP ${response.status}`) + } + + // 从 Content-Disposition 获取文件名 + const disposition = response.headers.get('Content-Disposition') + let filename = 'download' + if (disposition) { + // 解析 filename*=UTF-8''xxx 或 filename="xxx" + const utf8Match = disposition.match(/filename\*=UTF-8''(.+?)(?:;|$)/i) + if (utf8Match) { + filename = decodeURIComponent(utf8Match[1]) + } else { + const quotedMatch = disposition.match(/filename="(.+?)"/i) + if (quotedMatch) { + filename = decodeURIComponent(quotedMatch[1]) + } + } + } + + // 创建 Blob 并触发下载 + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + window.URL.revokeObjectURL(url) + } catch (e) { + console.error('下载失败:', e) + alert('下载失败:' + e.message) + } +} + /** * 删除文档 */