package com.wok.supportbot.controller; import com.wok.supportbot.entity.CategoryNode; import com.wok.supportbot.entity.KnowledgeCategory; import com.wok.supportbot.entity.KnowledgeDocument; import com.wok.supportbot.entity.SearchResult; import com.wok.supportbot.service.DocumentService; 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 org.springframework.web.multipart.MultipartFile; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; /** * 知识库文档管理控制器 * 提供文档上传、查询、管理、分类、搜索等完整功能 */ @RestController public class DocumentController { @Autowired private DocumentService documentService; // ==================== 上传校验常量 ==================== /** 允许上传的文件类型白名单 */ private static final Set ALLOWED_EXTENSIONS = Set.of( "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "json", "csv", "html", "xml", "rtf" ); /** 文件大小上限(50MB,与 application.yml 中 multipart 配置一致) */ private static final long MAX_FILE_SIZE = 50 * 1024 * 1024; /** * 校验上传文件 * @param file 上传的文件 */ private void validateUploadFile(MultipartFile file) { if (file.getSize() > MAX_FILE_SIZE) { throw new IllegalArgumentException("文件大小超过限制(最大 50MB)"); } String extension = getFileExtension(file.getOriginalFilename()); if (extension != null && !ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) { throw new IllegalArgumentException("不支持的文件类型: " + extension); } } /** * 获取文件扩展名 */ private String getFileExtension(String filename) { if (filename == null || !filename.contains(".")) { return null; } return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase(); } // ==================== 文档上传 ==================== /** * 上传普通文件(支持多种格式),用 Tika 解析 * * @param file 文件 * @param title 文档标题(可选,默认使用文件名) * @param categoryId 分类ID(可选) * @param tags 标签(可选) * @return 上传结果 */ @PostMapping("/upload/file") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> uploadFile( @RequestParam("file") MultipartFile file, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, @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); return ResponseEntity.ok(Map.of( "success", true, "message", "文件上传成功,正在后台处理", "data", doc )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "上传失败:" + e.getMessage() )); } } /** * 上传字符串内容 * * @param content 内容 * @param title 标题 * @param categoryId 分类ID(可选) * @param tags 标签(可选) * @return 上传结果 */ @PostMapping("/upload/string") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> uploadString( @RequestBody String content, @RequestParam String title, @RequestParam(required = false) Long categoryId, @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); return ResponseEntity.ok(Map.of( "success", true, "message", "文本内容上传成功,正在后台处理", "data", doc )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "上传失败:" + e.getMessage() )); } } /** * 上传 Markdown 文件 */ @PostMapping("/upload/markdown") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> uploadMarkdown( @RequestParam("file") MultipartFile file, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, @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); return ResponseEntity.ok(Map.of( "success", true, "message", "Markdown文件上传成功,正在后台处理", "data", doc )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "上传失败:" + e.getMessage() )); } } /** * 上传 JSON 文件(基本方式) */ @PostMapping("/upload/json/basic") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> uploadJsonBasic( @RequestParam("file") MultipartFile file, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, @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); return ResponseEntity.ok(Map.of( "success", true, "message", "JSON文件上传成功,正在后台处理", "data", doc )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "上传失败:" + e.getMessage() )); } } /** * 上传 JSON 文件(按字段提取) */ @PostMapping("/upload/json/fields") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> uploadJsonWithFields( @RequestParam("file") MultipartFile file, @RequestParam("fields") List fields, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, @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); return ResponseEntity.ok(Map.of( "success", true, "message", "JSON文件(按字段)上传成功,正在后台处理", "data", doc, "extractedFields", fields )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "上传失败:" + e.getMessage() )); } } /** * 上传 JSON 文件(按指针拆分) */ @PostMapping("/upload/json/pointer") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> uploadJsonWithPointer( @RequestParam("file") MultipartFile file, @RequestParam("pointer") String pointer, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, @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); return ResponseEntity.ok(Map.of( "success", true, "message", "JSON文件(按指针)上传成功,正在后台处理", "data", doc, "pointer", pointer )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "上传失败:" + e.getMessage() )); } } // ==================== 文档管理 ==================== /** * 查询文档列表(分页 + 过滤 + 关键词搜索 + 标签筛选) * * @param page 页码(默认1) * @param size 每页大小(默认10) * @param categoryId 分类ID过滤(可选) * @param status 状态过滤(PROCESSING/READY/FAILED,可选) * @param keyword 关键词搜索(模糊匹配标题和文件名,可选) * @param tag 标签筛选(精确匹配,可选) * @return 分页文档列表 */ @GetMapping("/document/list") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> listDocuments( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) Long categoryId, @RequestParam(required = false) String status, @RequestParam(required = false) String keyword, @RequestParam(required = false) String tag) { try { Map result = documentService.listDocuments(page, size, categoryId, status, keyword, tag); Map data = new HashMap<>(); data.put("success", true); data.put("data", result.get("records")); data.put("total", result.get("total")); data.put("page", result.get("page")); data.put("size", result.get("size")); data.put("pages", result.get("pages")); return ResponseEntity.ok(data); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "查询失败:" + e.getMessage() )); } } /** * 获取文档详情 */ @GetMapping("/document/{id}") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> getDocumentDetail(@PathVariable Long id) { try { KnowledgeDocument doc = documentService.getDocumentDetail(id); if (doc == null) { return ResponseEntity.status(404).body(Map.of( "success", false, "message", "文档不存在" )); } return ResponseEntity.ok(Map.of( "success", true, "data", doc )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "查询失败:" + e.getMessage() )); } } /** * 获取文档的所有分块 */ @GetMapping("/document/{id}/chunks") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> getDocumentChunks(@PathVariable Long id) { try { List> chunks = documentService.getDocumentChunks(id); return ResponseEntity.ok(Map.of( "success", true, "data", chunks, "total", chunks.size() )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "查询失败:" + e.getMessage() )); } } /** * 删除文档(逻辑删除 + 级联删除向量) */ @DeleteMapping("/document/{id}") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> deleteDocument(@PathVariable Long id) { try { int vectorCount = documentService.deleteDocument(id); return ResponseEntity.ok(Map.of( "success", true, "message", "删除成功", "deletedVectors", vectorCount )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "删除失败:" + e.getMessage() )); } } /** * 批量删除文档 */ @PostMapping("/document/batch/delete") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> batchDeleteDocuments(@RequestBody Map body) { try { List ids = extractIds(body); if (ids.isEmpty()) { return ResponseEntity.badRequest().body(Map.of( "success", false, "message", "请提供要删除的文档ID列表" )); } Map result = documentService.batchDeleteDocuments(ids); return ResponseEntity.ok(Map.of( "success", true, "message", String.format("批量删除完成:成功 %d 个,失败 %d 个", result.get("successCount"), result.get("failCount")), "data", result )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "批量删除失败:" + e.getMessage() )); } } /** * 批量重新处理文档 */ @PostMapping("/document/batch/reprocess") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> batchReprocessDocuments(@RequestBody Map body) { try { List ids = extractIds(body); if (ids.isEmpty()) { return ResponseEntity.badRequest().body(Map.of( "success", false, "message", "请提供要重新处理的文档ID列表" )); } Map result = documentService.batchReprocessDocuments(ids); return ResponseEntity.ok(Map.of( "success", true, "message", String.format("批量重新处理完成:成功 %d 个,失败 %d 个", result.get("successCount"), result.get("failCount")), "data", result )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "批量重新处理失败:" + e.getMessage() )); } } /** * 从请求体中提取 ID 列表(兼容字符串和数字类型的 ID) */ @SuppressWarnings("unchecked") private List extractIds(Map body) { List rawIds = (List) body.get("ids"); if (rawIds == null || rawIds.isEmpty()) { return List.of(); } return rawIds.stream().map(id -> { if (id instanceof Number) { return ((Number) id).longValue(); } else if (id instanceof String) { return Long.parseLong((String) id); } else { throw new IllegalArgumentException("无效的ID格式: " + id); } }).toList(); } /** * 更新文档元信息 */ @PutMapping("/document/{id}") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> updateDocument( @PathVariable Long id, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, @RequestParam(required = false) List tags) { try { documentService.updateDocumentMetadata(id, title, categoryId, tags); return ResponseEntity.ok(Map.of( "success", true, "message", "更新成功" )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "更新失败:" + e.getMessage() )); } } /** * 重新处理文档(重新分块 + 向量化) */ @PutMapping("/document/{id}/reprocess") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> reprocessDocument(@PathVariable Long id) { try { KnowledgeDocument doc = documentService.reprocessDocument(id); return ResponseEntity.ok(Map.of( "success", true, "message", "已提交重新处理,正在后台执行", "data", doc )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "重新处理失败:" + e.getMessage() )); } } /** * P1-2.1: 切换文档启用/禁用状态 * 禁用后不参与 RAG 检索但保留数据,启用后立即可被检索 */ @PutMapping("/document/{id}/toggle") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> toggleDocument(@PathVariable Long id) { try { KnowledgeDocument doc = documentService.toggleDocument(id); String statusText = Boolean.TRUE.equals(doc.getEnabled()) ? "已启用" : "已禁用"; return ResponseEntity.ok(Map.of( "success", true, "message", "文档" + statusText, "data", doc )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "切换状态失败:" + e.getMessage() )); } } /** * P1-2.1: 批量启用/禁用文档 * body: {ids: [Long], enabled: Boolean} */ @PostMapping("/document/batch/toggle") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> batchToggleDocuments(@RequestBody Map body) { try { List ids = extractIds(body); Boolean enabled = body.get("enabled") != null ? (Boolean) body.get("enabled") : null; if (ids.isEmpty()) { return ResponseEntity.badRequest().body(Map.of( "success", false, "message", "请提供文档ID列表" )); } if (enabled == null) { return ResponseEntity.badRequest().body(Map.of( "success", false, "message", "请指定目标状态(enabled: true/false)" )); } Map result = documentService.batchToggleDocuments(ids, enabled); String action = enabled ? "启用" : "禁用"; return ResponseEntity.ok(Map.of( "success", true, "message", String.format("批量%s完成:成功 %d 个,失败 %d 个", action, result.get("successCount"), result.get("failCount")), "data", result )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "批量切换状态失败:" + e.getMessage() )); } } /** * P1-2.2: 批量移动文档分类 * body: {ids: [Long], categoryId: Long} */ @PostMapping("/document/batch/move") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> batchMoveDocuments(@RequestBody Map body) { try { List ids = extractIds(body); Long categoryId = body.get("categoryId") != null ? ((Number) body.get("categoryId")).longValue() : 0L; if (ids.isEmpty()) { return ResponseEntity.badRequest().body(Map.of( "success", false, "message", "请提供文档ID列表" )); } int updatedCount = documentService.batchMoveDocuments(ids, categoryId); return ResponseEntity.ok(Map.of( "success", true, "message", String.format("已将 %d 个文档移动到目标分类", updatedCount) )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "批量移动失败:" + e.getMessage() )); } } // ==================== 语义搜索 ==================== /** * 语义搜索 * * @param body 搜索参数 * @return 搜索结果 */ @PostMapping("/document/search") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> searchDocuments(@RequestBody Map body) { try { String query = (String) body.get("query"); int topK = body.get("topK") != null ? ((Number) body.get("topK")).intValue() : 5; double similarityThreshold = body.get("similarityThreshold") != null ? ((Number) body.get("similarityThreshold")).doubleValue() : 0.5; Long categoryId = body.get("categoryId") != null ? ((Number) body.get("categoryId")).longValue() : null; List categoryIds = parseCategoryIds(body.get("categoryIds")); if (categoryIds.isEmpty() && categoryId != null) { categoryIds = List.of(categoryId); } String searchMode = (String) body.get("searchMode"); List results; if (searchMode != null && !searchMode.isEmpty()) { results = documentService.searchDocuments(query, topK, similarityThreshold, categoryIds, searchMode); } else { results = documentService.searchDocuments(query, topK, similarityThreshold, categoryIds); } return ResponseEntity.ok(Map.of( "success", true, "data", results, "total", results.size() )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "搜索失败:" + e.getMessage() )); } } private List parseCategoryIds(Object rawCategoryIds) { if (rawCategoryIds == null) { return List.of(); } if (rawCategoryIds instanceof List list) { return list.stream() .filter(Objects::nonNull) .map(item -> item instanceof Number number ? number.longValue() : Long.parseLong(item.toString())) .filter(id -> id > 0) .distinct() .toList(); } String text = rawCategoryIds.toString(); if (text.isBlank()) { return List.of(); } return Arrays.stream(text.split(",")) .map(String::trim) .filter(item -> !item.isEmpty()) .map(Long::parseLong) .filter(id -> id > 0) .distinct() .toList(); } // ==================== 标签管理 ==================== /** * P1-2.3: 获取标签列表(从所有文档的 tags 聚合去重,含使用次数) */ @GetMapping("/tag/list") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> getTagList() { try { List> tags = documentService.getTagList(); return ResponseEntity.ok(Map.of( "success", true, "data", tags, "total", tags.size() )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "获取标签列表失败:" + e.getMessage() )); } } // ==================== 分块管理 ==================== /** * P1-2.5: 更新单个分块内容(重新向量化该分块) */ @PutMapping("/document/{docId}/chunk/{chunkIndex}") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> updateChunk( @PathVariable Long docId, @PathVariable int chunkIndex, @RequestBody Map body) { try { String content = (String) body.get("content"); if (content == null || content.isBlank()) { return ResponseEntity.badRequest().body(Map.of( "success", false, "message", "分块内容不能为空" )); } documentService.updateChunk(docId, chunkIndex, content); return ResponseEntity.ok(Map.of( "success", true, "message", "分块内容已更新并重新向量化" )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "更新分块失败:" + e.getMessage() )); } } /** * P1-2.5: 删除单个分块(同步从 vector_store 移除向量) */ @DeleteMapping("/document/{docId}/chunk/{chunkIndex}") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> deleteChunk( @PathVariable Long docId, @PathVariable int chunkIndex) { try { documentService.deleteChunk(docId, chunkIndex); return ResponseEntity.ok(Map.of( "success", true, "message", "分块已删除" )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "删除分块失败:" + e.getMessage() )); } } // ==================== 统计 ==================== /** * 知识库统计面板 */ @GetMapping("/document/stats") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> getStats() { try { Map stats = documentService.getStats(); return ResponseEntity.ok(Map.of( "success", true, "data", stats )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "查询统计失败:" + e.getMessage() )); } } // ==================== 分类管理 ==================== /** * 获取分类树 */ @GetMapping("/category/tree") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> getCategoryTree() { try { List tree = documentService.getCategoryTree(); 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("/category/list") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> listCategories() { try { List list = documentService.listCategories(); 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("/category") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> createCategory(@RequestBody Map body) { try { String name = (String) body.get("name"); String description = (String) body.get("description"); Long parentId = body.get("parentId") != null ? ((Number) body.get("parentId")).longValue() : null; Integer sortOrder = body.get("sortOrder") != null ? ((Number) body.get("sortOrder")).intValue() : null; KnowledgeCategory category = documentService.createCategory(name, description, parentId, sortOrder); return ResponseEntity.ok(Map.of( "success", true, "message", "分类创建成功", "data", category )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "创建分类失败:" + e.getMessage() )); } } /** * 更新分类 */ @PutMapping("/category/{id}") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> updateCategory( @PathVariable Long id, @RequestBody Map body) { try { String name = (String) body.get("name"); String description = (String) body.get("description"); Integer sortOrder = body.get("sortOrder") != null ? ((Number) body.get("sortOrder")).intValue() : null; documentService.updateCategory(id, name, description, sortOrder); return ResponseEntity.ok(Map.of( "success", true, "message", "分类更新成功" )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "更新分类失败:" + e.getMessage() )); } } /** * 删除分类 */ @DeleteMapping("/category/{id}") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> deleteCategory(@PathVariable Long id) { try { documentService.deleteCategory(id); return ResponseEntity.ok(Map.of( "success", true, "message", "分类删除成功" )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, "message", "删除分类失败:" + e.getMessage() )); } } }