Browse Source

添加文件存储和下载功能

dev-mcp
wanghanlin 3 weeks ago
parent
commit
631398833a
  1. 19
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  2. 80
      src/main/java/com/wok/supportbot/config/FileStorageConfig.java
  3. 36
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  4. 35
      src/main/java/com/wok/supportbot/document/extract/JsonDocumentLoader.java
  5. 37
      src/main/java/com/wok/supportbot/document/extract/MarkdownDocumentLoader.java
  6. 41
      src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java
  7. 6
      src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java
  8. 122
      src/main/java/com/wok/supportbot/service/DocumentService.java
  9. 5
      src/main/resources/application-dev.yml
  10. 9
      src/main/resources/application-prod.yml
  11. 4
      src/main/resources/application.yml
  12. 7
      src/main/resources/init-database.sql
  13. 9
      src/main/resources/static/components/DocList.js
  14. 52
      src/main/resources/static/js/api.js

19
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=已删除");

80
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();
}
}

36
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()
));
}
}
/**
* 查询文档列表分页 + 过滤 + 关键词搜索 + 标签筛选
*

35
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<Document> loadBasicJsonFromFile(File file) {
Resource resource = new FileSystemResource(file);
JsonReader reader = new JsonReader(resource);
return reader.get();
}
/**
* 指定字段读取方式从文件
*/
public List<Document> loadJsonByFieldsFromFile(File file, String... fields) {
Resource resource = new FileSystemResource(file);
JsonReader reader = new JsonReader(resource, fields);
return reader.get();
}
/**
* 使用 JSON Pointer 提取数组路径内容从文件
*/
public List<Document> 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 {
}
/**
* 指定字段读取方式例如 descriptionfeatures 字段
* 指定字段读取方式
*/
public List<Document> loadJsonByFields(MultipartFile file, String... fields) {
Resource resource = toResource(file);
@ -35,7 +66,7 @@ public class JsonDocumentLoader {
}
/**
* 使用 JSON Pointer 提取数组路径内容 /items
* 使用 JSON Pointer 提取数组路径内容
*/
public List<Document> loadJsonByPointer(MultipartFile file, String pointer) {
Resource resource = toResource(file);

37
src/main/java/com/wok/supportbot/document/extract/MarkdownDocumentLoader.java

@ -20,14 +20,38 @@ import java.util.List;
@Slf4j
public class MarkdownDocumentLoader {
public List<Document> loadMarkdownFromFile(MultipartFile file) {
/**
* 从已保存的文件加载 Markdown
*/
public List<Document> 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<Document> 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<Document> docs = reader.get();
// 清理临时文件
temp.delete();
return docs;
} catch (IOException e) {
log.error("Markdown 文件解析失败", e);

41
src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java

@ -19,15 +19,34 @@ import java.util.UUID;
@Slf4j
public class TikaDocumentReader {
public List<Document> read(MultipartFile file) {
/**
* 从已保存的文件解析内容
*/
public List<Document> 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<Document> 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("."));
}
}

6
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;
/**
* 原文内容(截断预览)
*/

122
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<Document> documents, String title, String sourceName,
String fileType, Long fileSize, String content,
Long categoryId, List<String> 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<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = tikaDocumentReader.read(file);
// 1. 先保存原始文件到本地磁盘必须在解析之前因为 transferTo 只能调用一次
String relativePath = saveFileToLocal(file);
java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile();
// 2. 使用保存的文件进行解析避免 MultipartFile 的临时文件问题
List<Document> 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<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> 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<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = markdownDocumentLoader.loadMarkdownFromFile(file);
// 1. 先保存原始文件
String relativePath = saveFileToLocal(file);
java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile();
// 2. 使用保存的文件进行解析
List<Document> 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<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = jsonDocumentLoader.loadBasicJson(file);
// 1. 先保存原始文件
String relativePath = saveFileToLocal(file);
java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile();
// 2. 使用保存的文件进行解析
List<Document> 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<String> fields, String title,
Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = jsonDocumentLoader.loadJsonByFields(file, fields.toArray(new String[0]));
// 1. 先保存原始文件
String relativePath = saveFileToLocal(file);
java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile();
// 2. 使用保存的文件进行解析
List<Document> 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<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = jsonDocumentLoader.loadJsonByPointer(file, pointer);
// 1. 先保存原始文件
String relativePath = saveFileToLocal(file);
java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile();
// 2. 使用保存的文件进行解析
List<Document> 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<java.io.File, String> 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());
}
// ==================== 文档管理 ====================

5
src/main/resources/application-dev.yml

@ -23,6 +23,11 @@ spring:
knife4j:
enable: true
# ==================== 文件存储路径 ====================
knowledge:
storage:
local-path: D:/uploads
# ==================== 日志级别 ====================
logging:
level:

9
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

4
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 中配置(生产环境建议关闭)

7
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 '逻辑删除';

9
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 {
<td>{{ formatDate(d.createTime) }}</td>
<td>
<button class="btn btn-sm btn-outline" @click="viewDetail(d.id)">查看</button>
<button v-if="d.filePath" class="btn btn-sm btn-outline" @click="download(d.id)" title="下载原始文件">下载</button>
<button class="btn btn-sm btn-warn" @click="reprocess(d.id)">重新处理</button>
<button class="btn btn-sm btn-danger" @click="remove(d.id)">删除</button>
</td>
@ -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
}
}
}

52
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)
}
}
/**
* 删除文档
*/

Loading…
Cancel
Save