diff --git a/frontend/components.d.ts b/frontend/components.d.ts index bff7190..b72a1c3 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -47,6 +47,8 @@ declare module 'vue' { TTextarea: typeof import('tdesign-vue-next')['Textarea'] TTimeline: typeof import('tdesign-vue-next')['Timeline'] TTimelineItem: typeof import('tdesign-vue-next')['TimelineItem'] + TTree: typeof import('tdesign-vue-next')['Tree'] + TTreeSelect: typeof import('tdesign-vue-next')['TreeSelect'] TUpload: typeof import('tdesign-vue-next')['Upload'] } } diff --git a/frontend/src/api/document.ts b/frontend/src/api/document.ts index 53167ec..8b12a60 100644 --- a/frontend/src/api/document.ts +++ b/frontend/src/api/document.ts @@ -12,12 +12,15 @@ function authHeaders(): Record { // ==================== 文档 CRUD ==================== /** 文档列表(分页 + 过滤 + 搜索) */ -export function listDocuments(page = 1, size = 10, categoryId?: string, status?: string, keyword?: string, tag?: string): Promise { +export function listDocuments(page = 1, size = 10, categoryId?: string, status?: string, keyword?: string, tag?: string, folderId?: string, sortField?: string, sortOrder?: string): Promise { let path = `/document/list?page=${page}&size=${size}` if (categoryId) path += `&categoryId=${categoryId}` + if (folderId != null) path += `&folderId=${folderId}` if (status) path += `&status=${status}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` if (tag) path += `&tag=${encodeURIComponent(tag)}` + if (sortField) path += `&sortField=${sortField}` + if (sortOrder) path += `&sortOrder=${sortOrder}` return request.get(path).then(r => r.data) } diff --git a/frontend/src/api/folder.ts b/frontend/src/api/folder.ts new file mode 100644 index 0000000..02dce43 --- /dev/null +++ b/frontend/src/api/folder.ts @@ -0,0 +1,17 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' + +/** 获取目录树(可按分类过滤) */ +export function getFolderTree(categoryId?: string): Promise { return request.get(`/folder/tree${categoryId ? `?categoryId=${categoryId}` : ''}`).then(r => r.data) } + +/** 获取目录列表(可按分类过滤) */ +export function getFolderList(categoryId?: string): Promise { return request.get(`/folder/list${categoryId ? `?categoryId=${categoryId}` : ''}`).then(r => r.data) } + +/** 创建目录 */ +export function createFolder(data: any): Promise { return request.post('/folder', data).then(r => r.data) } + +/** 更新目录 */ +export function updateFolder(id: string, data: any): Promise { return request.put(`/folder/${id}`, data).then(r => r.data) } + +/** 删除目录 */ +export function deleteFolder(id: string): Promise { return request.delete(`/folder/${id}`).then(r => r.data) } diff --git a/frontend/src/api/upload.ts b/frontend/src/api/upload.ts index 1add1dc..114d125 100644 --- a/frontend/src/api/upload.ts +++ b/frontend/src/api/upload.ts @@ -24,6 +24,11 @@ export function uploadFile(formData: FormData, onProgress?: (pct: number) => voi return postFormWithProgress('/upload/file', formData, onProgress) } +/** 上传文件夹(批量文件 + 相对路径,复制目录结构) */ +export function uploadFolder(formData: FormData, onProgress?: (pct: number) => void): Promise { + return postFormWithProgress('/upload/folder', formData, onProgress) +} + /** 上传 Markdown(带进度) */ export function uploadMarkdown(formData: FormData, onProgress?: (pct: number) => void): Promise { return postFormWithProgress('/upload/markdown', formData, onProgress) diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts index b886d19..3981520 100644 --- a/frontend/src/types/models.ts +++ b/frontend/src/types/models.ts @@ -30,6 +30,7 @@ export interface KnowledgeDocument { id: string title: string categoryId: string + folderId?: string categoryName?: string tags?: string[] status: 'processing' | 'ready' | 'error' @@ -62,6 +63,20 @@ export interface KnowledgeCategory { children?: KnowledgeCategory[] } +/** 知识库目录(文件夹) */ +export interface KnowledgeFolder { + id: string + name: string + parentId: string + categoryId: string + sortOrder?: number + documentCount?: number + children?: KnowledgeFolder[] +} + +/** 目录节点(目录树中的节点,结构与 KnowledgeFolder 一致) */ +export type FolderNode = KnowledgeFolder + // ==================== FAQ 管理 ==================== /** FAQ 条目 */ diff --git a/frontend/src/views/DocList.vue b/frontend/src/views/DocList.vue index c402af8..eb231c5 100644 --- a/frontend/src/views/DocList.vue +++ b/frontend/src/views/DocList.vue @@ -1,70 +1,129 @@ diff --git a/frontend/src/views/DocUpload.vue b/frontend/src/views/DocUpload.vue index 085e2b1..898ceee 100644 --- a/frontend/src/views/DocUpload.vue +++ b/frontend/src/views/DocUpload.vue @@ -5,6 +5,16 @@
+
{{ tag }} +
+ 📁 选择文件夹上传 + 上传中 {{ folderProgress ?? 0 }}% + +
@@ -35,11 +50,13 @@ diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index a3a80dc..e499a23 100644 --- a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java +++ b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java @@ -62,6 +62,13 @@ public class DatabaseInitConfig { safeInit("迁移 knowledge_document.extra_config 列", this::addDocumentExtraConfigColumn); safeInit("迁移 knowledge_document.file_path 列", this::addDocumentFilePathColumn); + safeInit("创建知识文档目录表 knowledge_folder", () -> { + if (!checkTableExists("knowledge_folder")) { + createKnowledgeFolderTable(); + } + }); + safeInit("迁移 knowledge_document.folder_id 列", this::addDocumentFolderIdColumn); + safeInit("创建客服角色表 customer_service_role", () -> { if (!checkTableExists("customer_service_role")) { createCustomerServiceRoleTable(); @@ -269,6 +276,7 @@ public class DatabaseInitConfig { private void verifyInitialization() { String[] expectedTables = { "chat_message", "knowledge_category", "knowledge_document", + "knowledge_folder", "customer_service_role", "customer_service_role_category", "customer_account", "conversation_session", "ai_model_config", "sensitive_word", "content_audit_log", "message_feedback", @@ -347,6 +355,26 @@ public class DatabaseInitConfig { jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_category_parent ON knowledge_category (parent_id)"); } + private void createKnowledgeFolderTable() { + String sql = """ + CREATE TABLE IF NOT EXISTS knowledge_folder ( + id BIGSERIAL PRIMARY KEY, + category_id BIGINT DEFAULT 0 NOT NULL, + parent_id BIGINT DEFAULT 0 NOT NULL, + name VARCHAR(255) NOT NULL, + sort_order INTEGER DEFAULT 0 NOT NULL, + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + is_delete BOOLEAN DEFAULT FALSE NOT NULL + ) + """; + jdbcTemplate.execute(sql); + + // 创建索引 + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_folder_category ON knowledge_folder (category_id)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_folder_parent ON knowledge_folder (parent_id)"); + } + private void createDocumentTable() { String sql = """ CREATE TABLE IF NOT EXISTS knowledge_document ( @@ -685,6 +713,25 @@ public class DatabaseInitConfig { } } + /** + * 自动添加 folder_id 列(文档目录功能新增字段) + * 幂等:已有列则跳过 + */ + private void addDocumentFolderIdColumn() { + try { + String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'knowledge_document' AND column_name = 'folder_id'"; + Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); + if (count != null && count == 0) { + log.info("添加 knowledge_document.folder_id 列"); + jdbcTemplate.execute("ALTER TABLE knowledge_document ADD COLUMN folder_id BIGINT DEFAULT 0 NOT NULL"); + } + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_folder ON knowledge_document (folder_id)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_dedup ON knowledge_document (category_id, folder_id, content_hash)"); + } catch (Exception e) { + log.error("添加 knowledge_document.folder_id 列失败,请手动执行: ALTER TABLE knowledge_document ADD COLUMN folder_id BIGINT DEFAULT 0 NOT NULL", e); + } + } + // ==================== P0-004: 内容安全过滤 ==================== private void createSensitiveWordTable() { @@ -1460,6 +1507,7 @@ public class DatabaseInitConfig { executeComment("COLUMN knowledge_document.file_size", "文件大小(字节)"); executeComment("COLUMN knowledge_document.content", "原文内容(截断预览)"); executeComment("COLUMN knowledge_document.category_id", "所属分类 ID(0 表示未分类)"); + executeComment("COLUMN knowledge_document.folder_id", "所属目录 ID(0 表示未指定目录,直接挂分类根)"); executeComment("COLUMN knowledge_document.tags", "标签(JSON 格式)"); executeComment("COLUMN knowledge_document.chunk_count", "分块数量"); executeComment("COLUMN knowledge_document.status", "处理状态: PROCESSING / READY / FAILED"); @@ -1472,6 +1520,17 @@ public class DatabaseInitConfig { executeComment("COLUMN knowledge_document.update_time", "更新时间"); executeComment("COLUMN knowledge_document.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); + // ===== knowledge_folder ===== + executeComment("TABLE knowledge_folder", "知识库文档目录表(支持分类下的目录树形结构)"); + executeComment("COLUMN knowledge_folder.id", "主键(雪花算法生成)"); + executeComment("COLUMN knowledge_folder.category_id", "所属分类 ID(关联 knowledge_category.id)"); + executeComment("COLUMN knowledge_folder.parent_id", "父目录 ID(0 表示该分类下的根目录)"); + executeComment("COLUMN knowledge_folder.name", "目录名称"); + executeComment("COLUMN knowledge_folder.sort_order", "排序权重(数值越大越靠前)"); + executeComment("COLUMN knowledge_folder.create_time", "创建时间"); + executeComment("COLUMN knowledge_folder.update_time", "更新时间"); + executeComment("COLUMN knowledge_folder.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); + // ===== customer_service_role ===== executeComment("TABLE customer_service_role", "客服角色表(定义客服角色的身份与系统提示词)"); executeComment("COLUMN customer_service_role.id", "主键"); diff --git a/src/main/java/com/wok/supportbot/controller/DocumentController.java b/src/main/java/com/wok/supportbot/controller/DocumentController.java index cf95720..4058951 100644 --- a/src/main/java/com/wok/supportbot/controller/DocumentController.java +++ b/src/main/java/com/wok/supportbot/controller/DocumentController.java @@ -74,6 +74,7 @@ public class DocumentController { * @param file 文件 * @param title 文档标题(可选,默认使用文件名) * @param categoryId 分类ID(可选) + * @param folderId 目录ID(可选) * @param tags 标签(可选) * @return 上传结果 */ @@ -83,12 +84,13 @@ public class DocumentController { @RequestParam("file") MultipartFile file, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long folderId, @RequestParam(required = false) List tags, @RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer overlap) { try { validateUploadFile(file); - KnowledgeDocument doc = documentService.uploadFile(file, title, categoryId, tags, chunkSize, overlap); + KnowledgeDocument doc = documentService.uploadFile(file, title, categoryId, folderId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "文件上传成功,正在后台处理", @@ -117,11 +119,12 @@ public class DocumentController { @RequestBody String content, @RequestParam String title, @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long folderId, @RequestParam(required = false) List tags, @RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer overlap) { try { - KnowledgeDocument doc = documentService.uploadString(content, title, categoryId, tags, chunkSize, overlap); + KnowledgeDocument doc = documentService.uploadString(content, title, categoryId, folderId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "文本内容上传成功,正在后台处理", @@ -144,12 +147,13 @@ public class DocumentController { @RequestParam("file") MultipartFile file, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long folderId, @RequestParam(required = false) List tags, @RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer overlap) { try { validateUploadFile(file); - KnowledgeDocument doc = documentService.uploadMarkdown(file, title, categoryId, tags, chunkSize, overlap); + KnowledgeDocument doc = documentService.uploadMarkdown(file, title, categoryId, folderId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "Markdown文件上传成功,正在后台处理", @@ -172,12 +176,13 @@ public class DocumentController { @RequestParam("file") MultipartFile file, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long folderId, @RequestParam(required = false) List tags, @RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer overlap) { try { validateUploadFile(file); - KnowledgeDocument doc = documentService.uploadJsonBasic(file, title, categoryId, tags, chunkSize, overlap); + KnowledgeDocument doc = documentService.uploadJsonBasic(file, title, categoryId, folderId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "JSON文件上传成功,正在后台处理", @@ -201,12 +206,13 @@ public class DocumentController { @RequestParam("fields") List fields, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long folderId, @RequestParam(required = false) List tags, @RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer overlap) { try { validateUploadFile(file); - KnowledgeDocument doc = documentService.uploadJsonFields(file, fields, title, categoryId, tags, chunkSize, overlap); + KnowledgeDocument doc = documentService.uploadJsonFields(file, fields, title, categoryId, folderId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "JSON文件(按字段)上传成功,正在后台处理", @@ -231,12 +237,13 @@ public class DocumentController { @RequestParam("pointer") String pointer, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long folderId, @RequestParam(required = false) List tags, @RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer overlap) { try { validateUploadFile(file); - KnowledgeDocument doc = documentService.uploadJsonPointer(file, pointer, title, categoryId, tags, chunkSize, overlap); + KnowledgeDocument doc = documentService.uploadJsonPointer(file, pointer, title, categoryId, folderId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "JSON文件(按指针)上传成功,正在后台处理", @@ -251,6 +258,38 @@ public class DocumentController { } } + /** + * 文件夹批量上传(按相对路径自动创建子目录) + */ + @PostMapping("/upload/folder") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> uploadFolder( + @RequestParam("files") MultipartFile[] files, + @RequestParam("relativePaths") String[] relativePaths, + @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long folderId, + @RequestParam(required = false) List tags, + @RequestParam(required = false) Integer chunkSize, + @RequestParam(required = false) Integer overlap) { + try { + Map result = documentService.uploadFolder( + Arrays.asList(files), Arrays.asList(relativePaths), + categoryId, folderId, tags, chunkSize, overlap); + int successCount = (int) result.get("successCount"); + int failCount = (int) result.get("failCount"); + return ResponseEntity.ok(Map.of( + "success", true, + "message", String.format("文件夹上传完成:成功 %d 个,失败 %d 个", successCount, failCount), + "data", result + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "文件夹上传失败:" + e.getMessage() + )); + } + } + // ==================== 文档管理 ==================== /** @@ -295,6 +334,7 @@ public class DocumentController { * @param page 页码(默认1) * @param size 每页大小(默认10) * @param categoryId 分类ID过滤(可选) + * @param folderId 目录ID过滤(可选,0 表示只看根目录) * @param status 状态过滤(PROCESSING/READY/FAILED,可选) * @param keyword 关键词搜索(模糊匹配标题和文件名,可选) * @param tag 标签筛选(精确匹配,可选) @@ -306,11 +346,14 @@ public class DocumentController { @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long folderId, @RequestParam(required = false) String status, @RequestParam(required = false) String keyword, - @RequestParam(required = false) String tag) { + @RequestParam(required = false) String tag, + @RequestParam(required = false) String sortField, + @RequestParam(required = false) String sortOrder) { try { - Map result = documentService.listDocuments(page, size, categoryId, status, keyword, tag); + Map result = documentService.listDocuments(page, size, categoryId, folderId, status, keyword, tag, sortField, sortOrder); Map data = new HashMap<>(); data.put("success", true); data.put("data", result.get("records")); diff --git a/src/main/java/com/wok/supportbot/controller/FolderController.java b/src/main/java/com/wok/supportbot/controller/FolderController.java new file mode 100644 index 0000000..44fd224 --- /dev/null +++ b/src/main/java/com/wok/supportbot/controller/FolderController.java @@ -0,0 +1,136 @@ +package com.wok.supportbot.controller; + +import com.wok.supportbot.entity.FolderNode; +import com.wok.supportbot.entity.KnowledgeFolder; +import com.wok.supportbot.service.FolderService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 知识库文档目录控制器 + * 提供目录的树形查询、扁平列表、创建、重命名、删除等功能 + */ +@RestController +public class FolderController { + + @Autowired + private FolderService folderService; + + /** + * 获取目录树 + */ + @GetMapping("/folder/tree") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> getFolderTree(@RequestParam(required = false) Long categoryId) { + try { + List tree = folderService.getFolderTree(categoryId); + return ResponseEntity.ok(Map.of( + "success", true, + "data", tree + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "获取目录树失败:" + e.getMessage() + )); + } + } + + /** + * 获取目录扁平列表 + */ + @GetMapping("/folder/list") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> listFolders(@RequestParam(required = false) Long categoryId) { + try { + List list = folderService.listFolders(categoryId); + return ResponseEntity.ok(Map.of( + "success", true, + "data", list + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "获取目录列表失败:" + e.getMessage() + )); + } + } + + /** + * 创建目录 + */ + @PostMapping("/folder") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> createFolder(@RequestBody Map body) { + try { + String name = (String) body.get("name"); + Long categoryId = body.get("categoryId") != null ? Long.valueOf(body.get("categoryId").toString()) : null; + Long parentId = body.get("parentId") != null ? Long.valueOf(body.get("parentId").toString()) : null; + Integer sortOrder = body.get("sortOrder") != null ? Integer.valueOf(body.get("sortOrder").toString()) : null; + + KnowledgeFolder folder = folderService.createFolder(name, categoryId, parentId, sortOrder); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "目录创建成功", + "data", folder + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "创建目录失败:" + e.getMessage() + )); + } + } + + /** + * 重命名目录 / 调整排序 + */ + @PutMapping("/folder/{id}") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> renameFolder( + @PathVariable Long id, + @RequestBody Map body) { + try { + String name = (String) body.get("name"); + Integer sortOrder = body.get("sortOrder") != null ? Integer.valueOf(body.get("sortOrder").toString()) : null; + + KnowledgeFolder folder = folderService.renameFolder(id, name, sortOrder); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "目录更新成功", + "data", folder + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "更新目录失败:" + e.getMessage() + )); + } + } + + /** + * 删除目录(级联删除子孙目录,并将其下文档移到分类根) + */ + @DeleteMapping("/folder/{id}") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> deleteFolder(@PathVariable Long id) { + try { + Map result = folderService.deleteFolder(id); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "目录删除成功", + "data", result + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "删除目录失败:" + e.getMessage() + )); + } + } +} diff --git a/src/main/java/com/wok/supportbot/dao/KnowledgeFolderMapper.java b/src/main/java/com/wok/supportbot/dao/KnowledgeFolderMapper.java new file mode 100644 index 0000000..60ec159 --- /dev/null +++ b/src/main/java/com/wok/supportbot/dao/KnowledgeFolderMapper.java @@ -0,0 +1,12 @@ +package com.wok.supportbot.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wok.supportbot.entity.KnowledgeFolder; +import org.apache.ibatis.annotations.Mapper; + +/** + * 知识库文档目录 Mapper + */ +@Mapper +public interface KnowledgeFolderMapper extends BaseMapper { +} diff --git a/src/main/java/com/wok/supportbot/entity/FolderNode.java b/src/main/java/com/wok/supportbot/entity/FolderNode.java new file mode 100644 index 0000000..3fe4b2a --- /dev/null +++ b/src/main/java/com/wok/supportbot/entity/FolderNode.java @@ -0,0 +1,63 @@ +package com.wok.supportbot.entity; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 目录树节点 - 用于返回树形结构 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class FolderNode implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 目录ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** + * 目录名称 + */ + private String name; + + /** + * 所属分类ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long categoryId; + + /** + * 父目录ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long parentId; + + /** + * 排序权重 + */ + private Integer sortOrder; + + /** + * 目录内文档数量 + */ + private Integer documentCount; + + /** + * 子目录列表 + */ + private List children; +} diff --git a/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java b/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java index e927c12..f6653f9 100644 --- a/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java +++ b/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java @@ -75,6 +75,13 @@ public class KnowledgeDocument implements Serializable { @JsonSerialize(using = ToStringSerializer.class) private Long categoryId; + /** + * 所属目录ID - 0表示未指定目录(直接挂分类根) + */ + @TableField("folder_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long folderId; + /** * 标签列表(JSON数组) */ diff --git a/src/main/java/com/wok/supportbot/entity/KnowledgeFolder.java b/src/main/java/com/wok/supportbot/entity/KnowledgeFolder.java new file mode 100644 index 0000000..3897345 --- /dev/null +++ b/src/main/java/com/wok/supportbot/entity/KnowledgeFolder.java @@ -0,0 +1,80 @@ +package com.wok.supportbot.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 知识库文档目录表 - 支持分类下的目录树形结构 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@TableName("knowledge_folder") +public class KnowledgeFolder implements Serializable { + + @Serial + @TableField(exist = false) + private static final long serialVersionUID = 1L; + + /** + * 目录ID + */ + @TableId(value = "id", type = IdType.ASSIGN_ID) + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** + * 所属分类ID + */ + @TableField("category_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long categoryId; + + /** + * 父目录ID,0=该分类下的根目录 + */ + @TableField("parent_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long parentId; + + /** + * 目录名称 + */ + @TableField("name") + private String name; + + /** + * 排序权重(越大越靠前) + */ + @TableField("sort_order") + private Integer sortOrder; + + /** + * 创建时间 + */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新时间 + */ + @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + + /** + * 逻辑删除标志 - false:未删除, true:已删除(逻辑删除) + */ + @TableField("is_delete") + @TableLogic + private boolean isDelete; +} diff --git a/src/main/java/com/wok/supportbot/service/DocumentService.java b/src/main/java/com/wok/supportbot/service/DocumentService.java index d3abc4c..4ff9b66 100644 --- a/src/main/java/com/wok/supportbot/service/DocumentService.java +++ b/src/main/java/com/wok/supportbot/service/DocumentService.java @@ -12,6 +12,7 @@ import com.wok.supportbot.document.transform.MyTokenTextSplitter; import com.wok.supportbot.entity.CategoryNode; import com.wok.supportbot.entity.KnowledgeCategory; import com.wok.supportbot.entity.KnowledgeDocument; +import com.wok.supportbot.entity.KnowledgeFolder; import com.wok.supportbot.entity.SearchResult; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.document.Document; @@ -73,6 +74,9 @@ public class DocumentService { @Autowired private DocumentProcessingService documentProcessingService; + @Autowired + private FolderService folderService; + @Autowired private com.wok.supportbot.config.FileStorageConfig fileStorageConfig; @@ -97,6 +101,7 @@ public class DocumentService { * @param chunkSize 分块大小(可选,覆盖全局配置) * @param overlap 重叠大小(可选,覆盖全局配置) * @param filePath 原始文件存储路径(可选,null表示无原始文件) + * @param folderId 目录ID(可选,null/0表示不指定目录) * @return 创建完成的文档记录(status=PROCESSING) */ @Transactional(rollbackFor = Exception.class) @@ -104,13 +109,22 @@ public class DocumentService { String fileType, Long fileSize, String content, Long categoryId, List tags, Integer chunkSize, Integer overlap, - String filePath) { - // 0. 内容去重检查 + String filePath, Long folderId) { + // 0. 若指定目录,校验目录存在并强制使用目录所属分类 + if (folderId != null && folderId > 0) { + KnowledgeFolder folder = folderService.getFolderById(folderId); + if (folder == null) { + throw new RuntimeException("目录不存在"); + } + categoryId = folder.getCategoryId(); + } + + // 1. 内容去重检查 String contentHash = computeContentHash(content); if (contentHash != null) { - String duplicateTitle = checkContentDuplicate(contentHash, categoryId); + String duplicateTitle = checkContentDuplicate(contentHash, categoryId, folderId); if (duplicateTitle != null) { - throw new RuntimeException("文档内容重复,该分类下已有相同内容的文档: " + duplicateTitle); + throw new RuntimeException("文档内容重复,该目录下已有相同内容的文档: " + duplicateTitle); } } @@ -119,7 +133,7 @@ public class DocumentService { if (chunkSize != null) extraConfig.put("chunkSize", chunkSize); if (overlap != null) extraConfig.put("overlap", overlap); - // 1. 创建文档记录(状态 PROCESSING) + // 2. 创建文档记录(状态 PROCESSING) KnowledgeDocument docRecord = KnowledgeDocument.builder() .title(title != null ? title : sourceName) .sourceName(sourceName) @@ -128,6 +142,7 @@ public class DocumentService { .filePath(filePath) .content(content != null && content.length() > 2000 ? content.substring(0, 2000) : content) .categoryId(categoryId != null ? categoryId : 0L) + .folderId(folderId != null ? folderId : 0L) .tags(tags != null ? Map.of("tags", tags) : null) .contentHash(contentHash) .enabled(true) @@ -137,7 +152,7 @@ public class DocumentService { .build(); documentMapper.insert(docRecord); - // 2. 触发异步处理(分块 → 关键词 → 向量化 → 更新状态) + // 3. 触发异步处理(分块 → 关键词 → 向量化 → 更新状态) documentProcessingService.processDocumentAsync( docRecord.getId(), documents, sourceName, title, categoryId, tags, chunkSize, overlap); @@ -149,8 +164,8 @@ public class DocumentService { /** * 解析文件并上传(同时保存原始文件到本地) */ - public KnowledgeDocument uploadFile(MultipartFile file, String title, Long categoryId, List tags, - Integer chunkSize, Integer overlap) { + public KnowledgeDocument uploadFile(MultipartFile file, String title, Long categoryId, Long folderId, + List tags, Integer chunkSize, Integer overlap) { // 1. 先保存原始文件到本地磁盘(必须在解析之前,因为 transferTo 只能调用一次) String relativePath = saveFileToLocal(file); java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile(); @@ -168,7 +183,7 @@ public class DocumentService { documents.get(0).getText(), categoryId, tags, chunkSize, overlap, - relativePath); + relativePath, folderId); } /** @@ -201,18 +216,18 @@ public class DocumentService { /** * 解析字符串并上传(无原始文件) */ - public KnowledgeDocument uploadString(String content, String title, Long categoryId, List tags, - Integer chunkSize, Integer overlap) { + public KnowledgeDocument uploadString(String content, String title, Long categoryId, Long folderId, + 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, null); + (long) content.length(), content, categoryId, tags, chunkSize, overlap, null, folderId); } /** * 解析 Markdown 文件并上传 */ - public KnowledgeDocument uploadMarkdown(MultipartFile file, String title, Long categoryId, List tags, - Integer chunkSize, Integer overlap) { + public KnowledgeDocument uploadMarkdown(MultipartFile file, String title, Long categoryId, Long folderId, + List tags, Integer chunkSize, Integer overlap) { // 1. 先保存原始文件 String relativePath = saveFileToLocal(file); java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile(); @@ -229,14 +244,14 @@ public class DocumentService { content, categoryId, tags, chunkSize, overlap, - relativePath); + relativePath, folderId); } /** * 解析 JSON 文件(基本方式)并上传 */ - public KnowledgeDocument uploadJsonBasic(MultipartFile file, String title, Long categoryId, List tags, - Integer chunkSize, Integer overlap) { + public KnowledgeDocument uploadJsonBasic(MultipartFile file, String title, Long categoryId, Long folderId, + List tags, Integer chunkSize, Integer overlap) { // 1. 先保存原始文件 String relativePath = saveFileToLocal(file); java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile(); @@ -253,14 +268,14 @@ public class DocumentService { content, categoryId, tags, chunkSize, overlap, - relativePath); + relativePath, folderId); } /** * 解析 JSON 文件(按字段)并上传 */ public KnowledgeDocument uploadJsonFields(MultipartFile file, List fields, String title, - Long categoryId, List tags, + Long categoryId, Long folderId, List tags, Integer chunkSize, Integer overlap) { // 1. 先保存原始文件 String relativePath = saveFileToLocal(file); @@ -278,14 +293,14 @@ public class DocumentService { content, categoryId, tags, chunkSize, overlap, - relativePath); + relativePath, folderId); } /** * 解析 JSON 文件(按指针)并上传 */ public KnowledgeDocument uploadJsonPointer(MultipartFile file, String pointer, String title, - Long categoryId, List tags, + Long categoryId, Long folderId, List tags, Integer chunkSize, Integer overlap) { // 1. 先保存原始文件 String relativePath = saveFileToLocal(file); @@ -303,7 +318,117 @@ public class DocumentService { content, categoryId, tags, chunkSize, overlap, - relativePath); + relativePath, folderId); + } + + /** + * 文件夹批量上传:按相对路径解析子目录结构,逐文件上传 + * + * @param files 文件列表 + * @param relativePaths 每个文件对应的相对路径(如 "dir1/dir2/file.txt") + * @param categoryId 分类ID(可选,baseFolderId 为 0 时作为目标分类) + * @param folderId 基础目录ID(可选,null/0 表示从分类根目录开始) + * @param tags 标签列表(可选) + * @param chunkSize 分块大小(可选) + * @param overlap 重叠大小(可选) + * @return 批量上传结果(successCount/failCount/details) + */ + public Map uploadFolder(List files, List relativePaths, + Long categoryId, Long folderId, List tags, + Integer chunkSize, Integer overlap) { + if (files == null || files.isEmpty() || relativePaths == null || relativePaths.isEmpty()) { + throw new RuntimeException("文件和路径列表不能为空"); + } + if (files.size() != relativePaths.size()) { + throw new RuntimeException("文件与路径数量不一致"); + } + if (files.size() > 500) { + throw new RuntimeException("单次文件夹上传最多支持 500 个文件"); + } + + // 解析目标分类:基础目录存在时以其所属分类为准,否则使用入参分类 + Long baseFolderId = folderId != null ? folderId : 0L; + Long targetCategoryId; + if (baseFolderId > 0) { + KnowledgeFolder base = folderService.getFolderById(baseFolderId); + if (base == null) { + throw new RuntimeException("目录不存在"); + } + targetCategoryId = base.getCategoryId(); + } else { + targetCategoryId = categoryId; + } + + int successCount = 0; + int failCount = 0; + List> details = new ArrayList<>(); + + for (int i = 0; i < files.size(); i++) { + String originalPath = relativePaths.get(i); + try { + String sanitized = sanitizeRelativePath(originalPath); + List segs = new ArrayList<>(List.of(sanitized.split("/"))); + // 目录段 = 去掉最后一段(文件名) + List dirSegs = segs.subList(0, segs.size() - 1); + + Long targetFolderId; + if (!dirSegs.isEmpty()) { + if (targetCategoryId == null || targetCategoryId == 0) { + throw new RuntimeException("上传含子目录时必须先选择分类"); + } + targetFolderId = folderService.ensureFolderPath(targetCategoryId, baseFolderId, dirSegs); + } else { + targetFolderId = baseFolderId; + } + uploadFile(files.get(i), null, targetCategoryId, targetFolderId, tags, chunkSize, overlap); + successCount++; + } catch (Exception e) { + failCount++; + details.add(Map.of("file", originalPath, "error", e.getMessage())); + log.warn("文件夹上传单个文件失败: file={}", originalPath, e); + } + } + + Map result = new HashMap<>(); + result.put("successCount", successCount); + result.put("failCount", failCount); + result.put("details", details); + return result; + } + + /** + * 净化相对路径:反斜杠转正斜杠、去开头斜杠,校验非法段 + * + * @param path 原始相对路径 + * @return 净化后的路径 + */ + private String sanitizeRelativePath(String path) { + if (path == null || path.isEmpty()) { + throw new RuntimeException("文件相对路径不能为空"); + } + String normalized = path.replace('\\', '/'); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + if (normalized.isEmpty()) { + throw new RuntimeException("文件相对路径不能为空"); + } + String[] parts = normalized.split("/"); + for (String part : parts) { + if (part.isEmpty()) { + throw new RuntimeException("文件相对路径包含空目录段: " + path); + } + if (".".equals(part) || "..".equals(part)) { + throw new RuntimeException("文件相对路径包含非法目录段: " + path); + } + if (part.length() > 255) { + throw new RuntimeException("目录名超过 255 字符限制: " + part); + } + } + if (parts.length > 32) { + throw new RuntimeException("目录层级超过 32 层限制"); + } + return normalized; } // ==================== 文件下载 ==================== @@ -334,9 +459,10 @@ public class DocumentService { // ==================== 文档管理 ==================== /** - * 分页查询文档列表(手动分页,支持关键词搜索 + 标签筛选) + * 分页查询文档列表(手动分页,支持关键词搜索 + 标签筛选 + 目录筛选) */ - public Map listDocuments(int page, int size, Long categoryId, String status, String keyword, String tag) { + public Map listDocuments(int page, int size, Long categoryId, Long folderId, String status, + String keyword, String tag, String sortField, String sortOrder) { // 参数安全校验 if (page < 1) page = 1; if (size < 1 || size > 100) size = 10; @@ -349,6 +475,9 @@ public class DocumentService { if (categoryId != null && categoryId > 0) { countWrapper.eq("category_id", categoryId); } + if (folderId != null) { + countWrapper.eq("folder_id", folderId); + } if (status != null && !status.isEmpty()) { countWrapper.eq("status", status); } @@ -374,6 +503,9 @@ public class DocumentService { if (categoryId != null && categoryId > 0) { listWrapper.eq("category_id", categoryId); } + if (folderId != null) { + listWrapper.eq("folder_id", folderId); + } if (status != null && !status.isEmpty()) { listWrapper.eq("status", status); } @@ -389,7 +521,20 @@ public class DocumentService { log.warn("构建标签筛选 JSON 失败: tag={}", tag, e); } } - listWrapper.orderByDesc("create_time"); + // 排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 + Map sortColumns = Map.of( + "title", "title", + "fileType", "file_type", + "fileSize", "file_size", + "chunkCount", "chunk_count", + "createTime", "create_time"); + // sortField 可能为 null,需先判空,避免不可变 Map 的 getOrDefault(null) 触发 NPE + String sortColumn = sortField != null ? sortColumns.getOrDefault(sortField, "create_time") : "create_time"; + if ("asc".equalsIgnoreCase(sortOrder)) { + listWrapper.orderByAsc(sortColumn); + } else { + listWrapper.orderByDesc(sortColumn); + } listWrapper.last("LIMIT " + size + " OFFSET " + (page - 1) * size); List records = documentMapper.selectList(listWrapper); @@ -1035,10 +1180,13 @@ public class DocumentService { } /** - * 删除分类(不删除文档,仅清空关联) + * 删除分类(不删除文档,仅清空关联;级联清理该分类下的目录) */ @Transactional(rollbackFor = Exception.class) public void deleteCategory(Long id) { + // 级联清理该分类下的目录及其子孙目录(文档 folder_id 置 0,目录逻辑删除) + folderService.deleteFoldersByCategoryId(id); + // 将关联的文档 category_id 设为 0 KnowledgeDocument updateDoc = new KnowledgeDocument(); updateDoc.setCategoryId(0L); @@ -1074,15 +1222,18 @@ public class DocumentService { /** * 检查内容是否重复 * @param contentHash 内容哈希值 + * @param categoryId 分类ID + * @param folderId 目录ID(null/0 表示分类根目录) * @return 重复文档的标题,如果不存在重复则返回 null */ - private String checkContentDuplicate(String contentHash, Long categoryId) { + private String checkContentDuplicate(String contentHash, Long categoryId, Long folderId) { if (contentHash == null) { return null; } QueryWrapper wrapper = new QueryWrapper<>(); wrapper.eq("content_hash", contentHash); wrapper.eq("category_id", categoryId != null ? categoryId : 0L); + wrapper.eq("folder_id", folderId != null ? folderId : 0L); wrapper.select("title"); List existing = documentMapper.selectList(wrapper); return existing != null && !existing.isEmpty() ? existing.get(0).getTitle() : null; diff --git a/src/main/java/com/wok/supportbot/service/FolderService.java b/src/main/java/com/wok/supportbot/service/FolderService.java new file mode 100644 index 0000000..1ef3fc8 --- /dev/null +++ b/src/main/java/com/wok/supportbot/service/FolderService.java @@ -0,0 +1,313 @@ +package com.wok.supportbot.service; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.wok.supportbot.dao.KnowledgeCategoryMapper; +import com.wok.supportbot.dao.KnowledgeDocumentMapper; +import com.wok.supportbot.dao.KnowledgeFolderMapper; +import com.wok.supportbot.entity.FolderNode; +import com.wok.supportbot.entity.KnowledgeCategory; +import com.wok.supportbot.entity.KnowledgeDocument; +import com.wok.supportbot.entity.KnowledgeFolder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 知识库文档目录服务 + * 管理分类下的目录树形结构,支持目录的增删改查与级联处理 + */ +@Service +@Slf4j +public class FolderService { + + @Autowired + private KnowledgeFolderMapper folderMapper; + + @Autowired + private KnowledgeCategoryMapper categoryMapper; + + @Autowired + private KnowledgeDocumentMapper documentMapper; + + /** + * 获取目录树(按 parentId 拼装,parentId 为 null/0 的作为根节点) + * + * @param categoryId 分类ID(可选,为空时查询所有目录) + * @return 目录树根节点列表 + */ + public List getFolderTree(Long categoryId) { + QueryWrapper wrapper = new QueryWrapper<>(); + if (categoryId != null) { + wrapper.eq("category_id", categoryId); + } + wrapper.orderByAsc("sort_order"); + List folders = folderMapper.selectList(wrapper); + + Map nodeMap = new LinkedHashMap<>(); + List rootNodes = new ArrayList<>(); + + for (KnowledgeFolder folder : folders) { + FolderNode node = FolderNode.builder() + .id(folder.getId()) + .name(folder.getName()) + .categoryId(folder.getCategoryId()) + .parentId(folder.getParentId()) + .sortOrder(folder.getSortOrder()) + .documentCount(0) + .children(new ArrayList<>()) + .build(); + nodeMap.put(folder.getId(), node); + } + + for (FolderNode node : nodeMap.values()) { + if (node.getParentId() == null || node.getParentId() == 0) { + rootNodes.add(node); + } else { + FolderNode parent = nodeMap.get(node.getParentId()); + if (parent != null) { + parent.getChildren().add(node); + } else { + // 父目录缺失时降级为根节点,避免节点丢失 + rootNodes.add(node); + } + } + } + + return rootNodes; + } + + /** + * 获取目录扁平列表 + * + * @param categoryId 分类ID(可选) + * @return 目录列表 + */ + public List listFolders(Long categoryId) { + QueryWrapper wrapper = new QueryWrapper<>(); + if (categoryId != null) { + wrapper.eq("category_id", categoryId); + } + wrapper.orderByAsc("sort_order"); + return folderMapper.selectList(wrapper); + } + + /** + * 根据ID查询目录(逻辑删除的目录返回 null) + * + * @param id 目录ID + * @return 目录实体 + */ + public KnowledgeFolder getFolderById(Long id) { + if (id == null || id <= 0) { + return null; + } + return folderMapper.selectById(id); + } + + /** + * 创建目录 + * + * @param name 目录名称 + * @param categoryId 所属分类ID(必须存在且未删除) + * @param parentId 父目录ID(缺省/0 表示分类根目录) + * @param sortOrder 排序权重 + * @return 创建完成的目录实体 + */ + public KnowledgeFolder createFolder(String name, Long categoryId, Long parentId, Integer sortOrder) { + if (name == null || name.trim().isEmpty()) { + throw new RuntimeException("目录名称不能为空"); + } + name = name.trim(); + if (categoryId == null || categoryId <= 0) { + throw new RuntimeException("所属分类不存在"); + } + KnowledgeCategory category = categoryMapper.selectById(categoryId); + if (category == null || category.isDelete()) { + throw new RuntimeException("所属分类不存在"); + } + + Long parent = (parentId == null || parentId == 0) ? 0L : parentId; + if (parent > 0) { + KnowledgeFolder parentFolder = folderMapper.selectById(parent); + if (parentFolder == null || parentFolder.isDelete()) { + throw new RuntimeException("父目录不存在"); + } + if (parentFolder.getCategoryId() == null || !parentFolder.getCategoryId().equals(categoryId)) { + throw new RuntimeException("父目录不属于该分类"); + } + } + + // 同父同名查重 + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("category_id", categoryId); + wrapper.eq("parent_id", parent); + wrapper.eq("name", name); + if (folderMapper.selectCount(wrapper) > 0) { + throw new RuntimeException("同级目录下已存在同名目录"); + } + + KnowledgeFolder folder = KnowledgeFolder.builder() + .name(name) + .categoryId(categoryId) + .parentId(parent) + .sortOrder(sortOrder != null ? sortOrder : 0) + .build(); + folderMapper.insert(folder); + log.info("创建目录: id={}, name={}, categoryId={}, parentId={}", folder.getId(), name, categoryId, parent); + return folder; + } + + /** + * 重命名目录 / 调整排序 + * + * @param id 目录ID + * @param name 新名称(可选) + * @param sortOrder 新排序权重(可选) + * @return 更新后的目录实体 + */ + public KnowledgeFolder renameFolder(Long id, String name, Integer sortOrder) { + KnowledgeFolder folder = folderMapper.selectById(id); + if (folder == null) { + throw new RuntimeException("目录不存在"); + } + if (name != null && !name.trim().isEmpty()) { + String newName = name.trim(); + // 同父同名查重(排除自身) + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("category_id", folder.getCategoryId()); + wrapper.eq("parent_id", folder.getParentId()); + wrapper.eq("name", newName); + wrapper.ne("id", id); + if (folderMapper.selectCount(wrapper) > 0) { + throw new RuntimeException("同级目录下已存在同名目录"); + } + folder.setName(newName); + } + if (sortOrder != null) { + folder.setSortOrder(sortOrder); + } + folderMapper.updateById(folder); + return folder; + } + + /** + * 删除目录(级联逻辑删除自身与所有子孙目录,并将其下文档 folder_id 置 0) + * + * @param id 目录ID + * @return 删除结果(删除的目录数 + 移动的文档数) + */ + @Transactional(rollbackFor = Exception.class) + public Map deleteFolder(Long id) { + KnowledgeFolder folder = folderMapper.selectById(id); + if (folder == null) { + throw new RuntimeException("目录不存在"); + } + List ids = collectDescendantIds(id); + // 将该目录及其子孙目录下的文档 folder_id 置 0(不改变 categoryId) + KnowledgeDocument updateDoc = new KnowledgeDocument(); + updateDoc.setFolderId(0L); + int movedCount = documentMapper.update(updateDoc, + new QueryWrapper().in("folder_id", ids)); + // 逻辑删除目录(含子孙) + folderMapper.deleteBatchIds(ids); + log.info("删除目录及子孙: id={}, 删除目录数={}, 移动文档数={}", id, ids.size(), movedCount); + return Map.of("deletedFolders", ids.size(), "movedDocuments", movedCount); + } + + /** + * 清理指定分类下的所有目录及其子孙目录(用于删除分类时的级联处理) + * + * @param categoryId 分类ID + * @return 删除的目录数量 + */ + @Transactional(rollbackFor = Exception.class) + public int deleteFoldersByCategoryId(Long categoryId) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("category_id", categoryId); + List folders = folderMapper.selectList(wrapper); + + List ids = new ArrayList<>(); + for (KnowledgeFolder folder : folders) { + ids.addAll(collectDescendantIds(folder.getId())); + } + // 去重(多个根目录的子孙理论上不重叠,防御性处理) + ids = ids.stream().distinct().collect(Collectors.toList()); + if (ids.isEmpty()) { + return 0; + } + KnowledgeDocument updateDoc = new KnowledgeDocument(); + updateDoc.setFolderId(0L); + documentMapper.update(updateDoc, new QueryWrapper().in("folder_id", ids)); + folderMapper.deleteBatchIds(ids); + log.info("清理分类目录: categoryId={}, 删除目录数={}", categoryId, ids.size()); + return ids.size(); + } + + /** + * 从基础目录出发,逐段确保目录路径存在并返回最终目录ID + * + * @param categoryId 目标分类ID + * @param baseFolderId 基础目录ID(null/0 表示分类根目录) + * @param segments 目录段列表(不包含文件名) + * @return 最终目录ID + */ + public Long ensureFolderPath(Long categoryId, Long baseFolderId, List segments) { + Long currentParentId = (baseFolderId == null || baseFolderId == 0) ? 0L : baseFolderId; + for (String segment : segments) { + currentParentId = getOrCreateFolder(categoryId, currentParentId, segment); + } + return currentParentId; + } + + /** + * 在指定父目录下查找目录,不存在则创建,返回目录ID + * + * @param categoryId 分类ID + * @param parentId 父目录ID + * @param name 目录名称 + * @return 目录ID + */ + private Long getOrCreateFolder(Long categoryId, Long parentId, String name) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("category_id", categoryId); + wrapper.eq("parent_id", parentId != null ? parentId : 0L); + wrapper.eq("name", name); + List existing = folderMapper.selectList(wrapper); + if (existing != null && !existing.isEmpty()) { + return existing.get(0).getId(); + } + return createFolder(name, categoryId, parentId, null).getId(); + } + + /** + * 递归收集目录自身及所有子孙目录的ID + * + * @param rootId 根目录ID + * @return 目录ID列表(含根目录) + */ + private List collectDescendantIds(Long rootId) { + List all = new ArrayList<>(); + all.add(rootId); + List current = new ArrayList<>(); + current.add(rootId); + while (!current.isEmpty()) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.in("parent_id", current); + List children = folderMapper.selectList(wrapper); + List next = new ArrayList<>(); + for (KnowledgeFolder child : children) { + all.add(child.getId()); + next.add(child.getId()); + } + current = next; + } + return all; + } +} diff --git a/src/main/resources/init-database.sql b/src/main/resources/init-database.sql index 5ff61be..24349ec 100644 --- a/src/main/resources/init-database.sql +++ b/src/main/resources/init-database.sql @@ -1,7 +1,7 @@ -- ============================================================ -- AI 智能客服系统 - 数据库初始化脚本(完整版) -- 适用环境: PostgreSQL 12+ 且已安装 pgvector 扩展 --- 表数量: 27 张(含 vector_store;其余与 DatabaseInitConfig 的 26 张 expectedTables 对齐) +-- 表数量: 28 张(含 vector_store;其余与 DatabaseInitConfig 的 27 张 expectedTables 对齐) -- 注: vector_store 由 Spring AI 自动建表,此处手动建表作为备份方案 -- -- ⚠️ 开发规范:任何涉及建表、增删改列、初始数据的变更, @@ -87,6 +87,33 @@ COMMENT ON COLUMN knowledge_category.document_count IS '关联文档数(冗余 COMMENT ON COLUMN knowledge_category.create_time IS '创建时间'; COMMENT ON COLUMN knowledge_category.is_delete IS '逻辑删除'; +-- ============================================================ +-- 表 2.5: knowledge_folder — 知识库文档目录表 +-- ============================================================ +CREATE TABLE IF NOT EXISTS knowledge_folder ( + id BIGSERIAL PRIMARY KEY, + category_id BIGINT NOT NULL DEFAULT 0, + parent_id BIGINT NOT NULL DEFAULT 0, + name VARCHAR(255) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_delete BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_knowledge_folder_category ON knowledge_folder (category_id); +CREATE INDEX IF NOT EXISTS idx_knowledge_folder_parent ON knowledge_folder (parent_id); + +COMMENT ON TABLE knowledge_folder IS '知识库文档目录表(支持分类下的目录树形结构)'; +COMMENT ON COLUMN knowledge_folder.id IS '主键'; +COMMENT ON COLUMN knowledge_folder.category_id IS '所属分类ID(关联 knowledge_category.id)'; +COMMENT ON COLUMN knowledge_folder.parent_id IS '父目录ID(0 表示该分类下的根目录)'; +COMMENT ON COLUMN knowledge_folder.name IS '目录名称'; +COMMENT ON COLUMN knowledge_folder.sort_order IS '排序权重(越大越靠前)'; +COMMENT ON COLUMN knowledge_folder.create_time IS '创建时间'; +COMMENT ON COLUMN knowledge_folder.update_time IS '更新时间'; +COMMENT ON COLUMN knowledge_folder.is_delete IS '逻辑删除'; + -- ============================================================ -- 表 3: knowledge_document — 知识文档表 -- ============================================================ @@ -99,6 +126,7 @@ CREATE TABLE IF NOT EXISTS knowledge_document ( file_path VARCHAR(500), content TEXT, category_id BIGINT NOT NULL DEFAULT 0, + folder_id BIGINT NOT NULL DEFAULT 0, tags JSONB NOT NULL DEFAULT '{}', chunk_count INTEGER NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING', @@ -112,6 +140,8 @@ CREATE TABLE IF NOT EXISTS knowledge_document ( ); CREATE INDEX IF NOT EXISTS idx_knowledge_document_category ON knowledge_document (category_id); +CREATE INDEX IF NOT EXISTS idx_knowledge_document_folder ON knowledge_document (folder_id); +CREATE INDEX IF NOT EXISTS idx_knowledge_document_dedup ON knowledge_document (category_id, folder_id, content_hash); 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); @@ -126,6 +156,7 @@ 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.folder_id IS '所属目录ID(0 表示未指定目录,直接挂分类根)'; COMMENT ON COLUMN knowledge_document.tags IS '标签(JSON)'; COMMENT ON COLUMN knowledge_document.chunk_count IS '分块数量'; COMMENT ON COLUMN knowledge_document.status IS '状态: PROCESSING / READY / FAILED'; @@ -137,6 +168,9 @@ COMMENT ON COLUMN knowledge_document.create_time IS '创建时间'; COMMENT ON COLUMN knowledge_document.update_time IS '更新时间'; COMMENT ON COLUMN knowledge_document.is_delete IS '逻辑删除'; +-- 老库升级:补充 folder_id 列(新建库已在 CREATE TABLE 中包含,此句幂等) +ALTER TABLE knowledge_document ADD COLUMN IF NOT EXISTS folder_id BIGINT DEFAULT 0 NOT NULL; + -- ============================================================ -- 表 4: customer_service_role — 客服角色表 -- ============================================================