From 541c3ff45588243d6deadad8873dcc54218e0e08 Mon Sep 17 00:00:00 2001 From: "zhiye.sun" Date: Thu, 21 May 2026 15:34:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(rag-document):=20=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3=E4=B8=8E?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/api/__tests__/ragDocuments.spec.ts | 96 ++++ frontend/src/api/ragDocuments.ts | 88 ++++ frontend/src/pages/RagDocumentsPage.vue | 472 +++++++++++++++++- .../pages/__tests__/RagDocumentsPage.spec.ts | 125 +++++ .../rag/constant/RagSystemConstants.java | 5 + .../rag/controller/RagDocumentController.java | 59 ++- .../RagDocumentBatchUploadRequest.java | 25 + .../dto/request/RagDocumentSaveRequest.java | 39 ++ .../rag/dto/response/RagDocumentResponse.java | 17 + .../rag/service/IRagDocumentService.java | 10 + .../service/impl/RagDocumentServiceImpl.java | 188 ++++++- .../rag/RagDocumentServiceImplTests.java | 124 +++++ 12 files changed, 1233 insertions(+), 15 deletions(-) create mode 100644 frontend/src/api/__tests__/ragDocuments.spec.ts create mode 100644 frontend/src/api/ragDocuments.ts create mode 100644 frontend/src/pages/__tests__/RagDocumentsPage.spec.ts create mode 100644 src/main/java/com/bruce/rag/dto/request/RagDocumentBatchUploadRequest.java create mode 100644 src/main/java/com/bruce/rag/dto/request/RagDocumentSaveRequest.java create mode 100644 src/test/java/com/bruce/rag/RagDocumentServiceImplTests.java diff --git a/frontend/src/api/__tests__/ragDocuments.spec.ts b/frontend/src/api/__tests__/ragDocuments.spec.ts new file mode 100644 index 0000000..fd1b1e4 --- /dev/null +++ b/frontend/src/api/__tests__/ragDocuments.spec.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + batchUploadRagDocuments, + deleteRagDocument, + getRagDocumentById, + listRagDocuments, + queryRagDocuments, + saveRagDocument, + SOURCE_TYPE_RAG, +} from '../ragDocuments'; +import { get, post } from '../request'; + +vi.mock('../request', () => ({ + get: vi.fn(), + post: vi.fn(), +})); + +describe('rag documents api', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('lists documents with explicit action url', () => { + listRagDocuments(); + + expect(post).toHaveBeenCalledWith('/rag/documents/list'); + }); + + it('queries details and saves documents', () => { + queryRagDocuments({ storeId: '1001', parseStatus: 'UPLOADED' }); + getRagDocumentById('2002'); + saveRagDocument({ + id: '2002', + storeId: '1001', + attachmentId: '3003', + documentTitle: '知识文档', + parseStatus: 'PARSED', + indexStatus: 'INDEXED', + enabled: true, + errorMessage: '无', + remark: '备注', + }); + deleteRagDocument('2002'); + + expect(post).toHaveBeenCalledWith('/rag/documents/query', { + storeId: '1001', + parseStatus: 'UPLOADED', + }); + expect(get).toHaveBeenCalledWith('/rag/documents/detail', { + params: { id: '2002' }, + }); + expect(post).toHaveBeenCalledWith('/rag/documents/save', { + id: '2002', + storeId: '1001', + attachmentId: '3003', + documentTitle: '知识文档', + parseStatus: 'PARSED', + indexStatus: 'INDEXED', + enabled: true, + errorMessage: '无', + remark: '备注', + }); + expect(post).toHaveBeenCalledWith('/rag/documents/delete', undefined, { + params: { id: '2002' }, + }); + }); + + it('batch uploads documents with constant sourceType and storeId sourceId', () => { + const formData = new FormData(); + const file = new File(['hello'], 'knowledge.txt', { type: 'text/plain' }); + + batchUploadRagDocuments({ + storeId: '1001', + sourceType: SOURCE_TYPE_RAG, + files: [file], + documentSummary: '摘要', + remark: '备注', + }); + + expect(post).toHaveBeenCalledTimes(1); + const [url, body] = vi.mocked(post).mock.calls[0] ?? []; + expect(url).toBe('/rag/documents/batchUpload'); + expect(body).toBeInstanceOf(FormData); + + formData.append('storeId', '1001'); + formData.append('sourceType', SOURCE_TYPE_RAG); + formData.append('files', file); + formData.append('documentSummary', '摘要'); + formData.append('remark', '备注'); + + const actualEntries = Array.from((body as FormData).entries()); + const expectedEntries = Array.from(formData.entries()); + expect(actualEntries).toEqual(expectedEntries); + }); +}); diff --git a/frontend/src/api/ragDocuments.ts b/frontend/src/api/ragDocuments.ts new file mode 100644 index 0000000..9c1aff0 --- /dev/null +++ b/frontend/src/api/ragDocuments.ts @@ -0,0 +1,88 @@ +import { get, post } from './request'; + +/** 附件来源类型:标识该附件归属于 RAG 知识库业务。 */ +export const SOURCE_TYPE_RAG = 'RAG'; + +export interface RagDocument { + id?: string; + storeId: string; + attachmentId?: string | null; + documentTitle?: string | null; + documentSummary?: string | null; + parseStatus?: string | null; + indexStatus?: string | null; + enabled?: boolean | null; + errorMessage?: string | null; + remark?: string | null; + createTime?: string | null; + updateTime?: string | null; +} + +export interface RagDocumentQueryRequest { + storeId?: string; + attachmentId?: string; + parseStatus?: string; + indexStatus?: string; + enabled?: boolean; +} + +export interface RagDocumentSaveRequest { + id?: string; + storeId: string; + attachmentId?: string; + documentTitle?: string; + documentSummary?: string; + parseStatus?: string; + indexStatus?: string; + enabled?: boolean; + errorMessage?: string; + remark?: string; +} + +export interface RagDocumentBatchUploadRequest { + storeId: string; + sourceType?: string; + files: File[]; + documentSummary?: string; + remark?: string; +} + +export function listRagDocuments() { + return post('/rag/documents/list'); +} + +export function queryRagDocuments(query?: RagDocumentQueryRequest) { + return post('/rag/documents/query', query); +} + +export function getRagDocumentById(id: string) { + return get('/rag/documents/detail', { + params: { id }, + }); +} + +export function saveRagDocument(data: RagDocumentSaveRequest) { + return post('/rag/documents/save', data); +} + +export function deleteRagDocument(id: string) { + return post('/rag/documents/delete', undefined, { + params: { id }, + }); +} + +export function batchUploadRagDocuments(data: RagDocumentBatchUploadRequest) { + const formData = new FormData(); + formData.append('storeId', data.storeId); + formData.append('sourceType', data.sourceType || SOURCE_TYPE_RAG); + data.files.forEach((file) => { + formData.append('files', file); + }); + if (data.documentSummary) { + formData.append('documentSummary', data.documentSummary); + } + if (data.remark) { + formData.append('remark', data.remark); + } + return post('/rag/documents/batchUpload', formData); +} diff --git a/frontend/src/pages/RagDocumentsPage.vue b/frontend/src/pages/RagDocumentsPage.vue index 9072ea9..71176c6 100644 --- a/frontend/src/pages/RagDocumentsPage.vue +++ b/frontend/src/pages/RagDocumentsPage.vue @@ -1,8 +1,478 @@ + + + + diff --git a/frontend/src/pages/__tests__/RagDocumentsPage.spec.ts b/frontend/src/pages/__tests__/RagDocumentsPage.spec.ts new file mode 100644 index 0000000..49c6988 --- /dev/null +++ b/frontend/src/pages/__tests__/RagDocumentsPage.spec.ts @@ -0,0 +1,125 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import ElementPlus from 'element-plus'; +import { describe, expect, it, vi } from 'vitest'; + +import RagDocumentsPage from '../RagDocumentsPage.vue'; +import { getRagDocumentById, listRagDocuments, queryRagDocuments } from '@/api/ragDocuments'; +import { queryRagStores } from '@/api/ragStores'; + +vi.mock('@/api/ragStores', () => ({ + queryRagStores: vi.fn(() => + Promise.resolve({ + resultcode: '0', + message: null, + data: [ + { id: '1', storeCode: 'PROD_DOC', storeName: '产品制度库', status: '启用' }, + { id: '2', storeCode: 'FAQ', storeName: 'FAQ知识库', status: '停用' }, + ], + }), + ), +})); + +vi.mock('@/api/ragDocuments', () => ({ + SOURCE_TYPE_RAG: 'RAG', + listRagDocuments: vi.fn(() => + Promise.resolve({ + resultcode: '0', + message: null, + data: [ + { + id: '11', + storeId: '1', + attachmentId: '101', + documentTitle: '产品制度总则', + documentSummary: '制度摘要', + parseStatus: 'UPLOADED', + indexStatus: 'PENDING', + enabled: true, + remark: '制度文档', + createTime: '2026-05-21 10:00:00', + }, + { + id: '22', + storeId: '2', + attachmentId: '202', + documentTitle: 'FAQ 手册', + documentSummary: 'FAQ 摘要', + parseStatus: 'PARSED', + indexStatus: 'INDEXED', + enabled: false, + remark: '常见问题', + createTime: '2026-05-21 11:00:00', + }, + ], + }), + ), + queryRagDocuments: vi.fn(), + getRagDocumentById: vi.fn((id: string) => + Promise.resolve({ + resultcode: '0', + message: null, + data: { + id, + storeId: '2', + attachmentId: '202', + documentTitle: 'FAQ 手册', + documentSummary: 'FAQ 摘要', + enabled: false, + remark: '常见问题', + }, + }), + ), + saveRagDocument: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })), + deleteRagDocument: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })), + batchUploadRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })), +})); + +describe('RagDocumentsPage', () => { + it('loads documents from list api instead of broken query api', async () => { + const wrapper = mount(RagDocumentsPage, { + global: { + plugins: [ElementPlus], + }, + }); + + await flushPromises(); + + expect(queryRagStores).toHaveBeenCalled(); + expect(listRagDocuments).toHaveBeenCalled(); + expect(queryRagDocuments).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain('产品制度总则'); + expect(wrapper.text()).toContain('FAQ 手册'); + }); + + it('filters rows locally and still avoids query api on search', async () => { + const wrapper = mount(RagDocumentsPage, { + global: { + plugins: [ElementPlus], + }, + }); + + await flushPromises(); + await wrapper.get('[data-test="doc-keyword-input"]').setValue('FAQ'); + await wrapper.get('[data-test="doc-search"]').trigger('click'); + await flushPromises(); + + expect(listRagDocuments).toHaveBeenCalled(); + expect(queryRagDocuments).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain('FAQ 手册'); + expect(wrapper.text()).not.toContain('产品制度总则'); + }); + + it('loads backend detail when editing a row', async () => { + const wrapper = mount(RagDocumentsPage, { + global: { + plugins: [ElementPlus], + }, + }); + + await flushPromises(); + await wrapper.get('[data-test="doc-edit-22"]').trigger('click'); + await flushPromises(); + + expect(getRagDocumentById).toHaveBeenCalledWith('22'); + }); +}); diff --git a/src/main/java/com/bruce/rag/constant/RagSystemConstants.java b/src/main/java/com/bruce/rag/constant/RagSystemConstants.java index 78b360a..85647ec 100644 --- a/src/main/java/com/bruce/rag/constant/RagSystemConstants.java +++ b/src/main/java/com/bruce/rag/constant/RagSystemConstants.java @@ -6,6 +6,11 @@ public final class RagSystemConstants { public static final String RAG_DOCUMENT = "RAG_DOCUMENT"; + /** + * 用于 sys_attachment.sourceType 标识该附件归属于 RAG 知识库业务。 + */ + public static final String SOURCE_TYPE_RAG = "RAG"; + private RagSystemConstants() { } } diff --git a/src/main/java/com/bruce/rag/controller/RagDocumentController.java b/src/main/java/com/bruce/rag/controller/RagDocumentController.java index 408d239..777797c 100644 --- a/src/main/java/com/bruce/rag/controller/RagDocumentController.java +++ b/src/main/java/com/bruce/rag/controller/RagDocumentController.java @@ -1,21 +1,27 @@ package com.bruce.rag.controller; import com.bruce.common.domain.model.RequestResult; +import com.bruce.rag.dto.request.RagDocumentBatchUploadRequest; import com.bruce.rag.dto.request.RagDocumentQueryRequest; +import com.bruce.rag.dto.request.RagDocumentSaveRequest; import com.bruce.rag.dto.response.RagDocumentResponse; import com.bruce.rag.service.IRagDocumentService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; @Tag(name = "RAG知识库文档管理") +@Slf4j @RestController @RequestMapping("/api/rag/documents") public class RagDocumentController { @@ -24,14 +30,59 @@ public class RagDocumentController { private IRagDocumentService ragDocumentService; @Operation(summary = "查询全部知识库文档") - @GetMapping + @PostMapping("/list") public RequestResult> list() { - return RequestResult.success(ragDocumentService.listResponses()); + log.info("RagDocumentController.list start"); + List responses = ragDocumentService.listResponses(); + log.info("RagDocumentController.list success, count={}", responses.size()); + return RequestResult.success(responses); } @Operation(summary = "按条件查询知识库文档") @PostMapping("/query") - public RequestResult> query(@RequestBody RagDocumentQueryRequest request) { - return RequestResult.success(ragDocumentService.query(request)); + public RequestResult> query(@RequestBody(required = false) RagDocumentQueryRequest request) { + log.info("RagDocumentController.query start, request={}", request); + List responses = ragDocumentService.query(request); + log.info("RagDocumentController.query success, count={}", responses.size()); + return RequestResult.success(responses); + } + + @Operation(summary = "查询知识库文档详情") + @GetMapping("/detail") + public RequestResult getById(@RequestParam("id") Long id) { + log.info("RagDocumentController.getById start, id={}", id); + RagDocumentResponse response = ragDocumentService.getResponseById(id); + log.info("RagDocumentController.getById success, id={}, found={}", id, response != null); + return RequestResult.success(response); + } + + @Operation(summary = "新增或修改知识库文档") + @PostMapping("/save") + public RequestResult saveOrUpdate(@RequestBody RagDocumentSaveRequest request) { + log.info("RagDocumentController.saveOrUpdate start, request={}", request); + Boolean result = ragDocumentService.saveOrUpdate(request); + log.info("RagDocumentController.saveOrUpdate success, id={}, result={}", + request.getId(), result); + return RequestResult.success(result); + } + + @Operation(summary = "删除知识库文档") + @PostMapping("/delete") + public RequestResult deleteById(@RequestParam("id") Long id) { + log.info("RagDocumentController.deleteById start, id={}", id); + Boolean result = ragDocumentService.removeById(id); + log.info("RagDocumentController.deleteById success, id={}, result={}", id, result); + return RequestResult.success(result); + } + + @Operation(summary = "批量上传文档到知识库") + @PostMapping("/batchUpload") + public RequestResult> batchUpload(@ModelAttribute RagDocumentBatchUploadRequest request) { + log.info("RagDocumentController.batchUpload start, storeId={}, fileCount={}", + request.getStoreId(), request.getFiles() != null ? request.getFiles().length : 0); + List responses = ragDocumentService.batchUpload(request); + log.info("RagDocumentController.batchUpload success, storeId={}, uploaded={}", + request.getStoreId(), responses.size()); + return RequestResult.success(responses); } } diff --git a/src/main/java/com/bruce/rag/dto/request/RagDocumentBatchUploadRequest.java b/src/main/java/com/bruce/rag/dto/request/RagDocumentBatchUploadRequest.java new file mode 100644 index 0000000..54d6d5d --- /dev/null +++ b/src/main/java/com/bruce/rag/dto/request/RagDocumentBatchUploadRequest.java @@ -0,0 +1,25 @@ +package com.bruce.rag.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.web.multipart.MultipartFile; + +@Data +@Schema(description = "RAG知识库文档批量上传请求") +public class RagDocumentBatchUploadRequest { + + @Schema(description = "知识库ID") + private Long storeId; + + @Schema(description = "附件来源类型,RAG 文档上传固定传 RAG") + private String sourceType; + + @Schema(description = "上传文件列表") + private MultipartFile[] files; + + @Schema(description = "文档摘要(批量设置)") + private String documentSummary; + + @Schema(description = "备注(批量设置)") + private String remark; +} diff --git a/src/main/java/com/bruce/rag/dto/request/RagDocumentSaveRequest.java b/src/main/java/com/bruce/rag/dto/request/RagDocumentSaveRequest.java new file mode 100644 index 0000000..36adc28 --- /dev/null +++ b/src/main/java/com/bruce/rag/dto/request/RagDocumentSaveRequest.java @@ -0,0 +1,39 @@ +package com.bruce.rag.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "RAG知识库文档保存请求") +public class RagDocumentSaveRequest { + + @Schema(description = "主键ID(更新时必填)") + private Long id; + + @Schema(description = "知识库ID") + private Long storeId; + + @Schema(description = "附件ID") + private Long attachmentId; + + @Schema(description = "文档标题") + private String documentTitle; + + @Schema(description = "文档摘要") + private String documentSummary; + + @Schema(description = "解析状态") + private String parseStatus; + + @Schema(description = "索引状态") + private String indexStatus; + + @Schema(description = "是否启用") + private Boolean enabled; + + @Schema(description = "失败原因") + private String errorMessage; + + @Schema(description = "备注") + private String remark; +} diff --git a/src/main/java/com/bruce/rag/dto/response/RagDocumentResponse.java b/src/main/java/com/bruce/rag/dto/response/RagDocumentResponse.java index 925cb27..bec7a9f 100644 --- a/src/main/java/com/bruce/rag/dto/response/RagDocumentResponse.java +++ b/src/main/java/com/bruce/rag/dto/response/RagDocumentResponse.java @@ -1,21 +1,30 @@ package com.bruce.rag.dto.response; +import com.bruce.common.constant.CommonConsts; import com.bruce.rag.entity.RagDocument; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.springframework.beans.BeanUtils; +import java.util.Date; + @Data @Schema(description = "RAG知识库文档响应") public class RagDocumentResponse { @Schema(description = "主键ID") + @JsonSerialize(using = ToStringSerializer.class) private Long id; @Schema(description = "知识库ID") + @JsonSerialize(using = ToStringSerializer.class) private Long storeId; @Schema(description = "附件ID") + @JsonSerialize(using = ToStringSerializer.class) private Long attachmentId; @Schema(description = "文档标题") @@ -39,6 +48,14 @@ public class RagDocumentResponse { @Schema(description = "备注") private String remark; + @Schema(description = "创建时间") + @JsonFormat(pattern = CommonConsts.DATE_FORMAT_LONG_STR, timezone = CommonConsts.TIME_ZONE_GMT8) + private Date createTime; + + @Schema(description = "更新时间") + @JsonFormat(pattern = CommonConsts.DATE_FORMAT_LONG_STR, timezone = CommonConsts.TIME_ZONE_GMT8) + private Date updateTime; + public static RagDocumentResponse fromEntity(RagDocument entity) { if (entity == null) { return null; diff --git a/src/main/java/com/bruce/rag/service/IRagDocumentService.java b/src/main/java/com/bruce/rag/service/IRagDocumentService.java index 6f59825..80d0173 100644 --- a/src/main/java/com/bruce/rag/service/IRagDocumentService.java +++ b/src/main/java/com/bruce/rag/service/IRagDocumentService.java @@ -1,7 +1,9 @@ package com.bruce.rag.service; import com.baomidou.mybatisplus.extension.service.IService; +import com.bruce.rag.dto.request.RagDocumentBatchUploadRequest; import com.bruce.rag.dto.request.RagDocumentQueryRequest; +import com.bruce.rag.dto.request.RagDocumentSaveRequest; import com.bruce.rag.dto.response.RagDocumentResponse; import com.bruce.rag.entity.RagDocument; @@ -12,4 +14,12 @@ public interface IRagDocumentService extends IService { List listResponses(); List query(RagDocumentQueryRequest request); + + RagDocumentResponse getResponseById(Long id); + + boolean saveOrUpdate(RagDocumentSaveRequest request); + + boolean removeById(Long id); + + List batchUpload(RagDocumentBatchUploadRequest request); } diff --git a/src/main/java/com/bruce/rag/service/impl/RagDocumentServiceImpl.java b/src/main/java/com/bruce/rag/service/impl/RagDocumentServiceImpl.java index 4399bc8..378d1a5 100644 --- a/src/main/java/com/bruce/rag/service/impl/RagDocumentServiceImpl.java +++ b/src/main/java/com/bruce/rag/service/impl/RagDocumentServiceImpl.java @@ -1,36 +1,190 @@ package com.bruce.rag.service.impl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bruce.common.domain.entity.SysAttachment; +import com.bruce.common.dto.request.SysAttachmentUploadRequest; +import com.bruce.common.service.ISysAttachmentService; +import com.bruce.rag.constant.RagSystemConstants; +import com.bruce.rag.dto.request.RagDocumentBatchUploadRequest; import com.bruce.rag.dto.request.RagDocumentQueryRequest; +import com.bruce.rag.dto.request.RagDocumentSaveRequest; import com.bruce.rag.dto.response.RagDocumentResponse; import com.bruce.rag.entity.RagDocument; +import com.bruce.rag.enums.RagIndexStatusEnum; +import com.bruce.rag.enums.RagParseStatusEnum; import com.bruce.rag.mapper.RagDocumentMapper; import com.bruce.rag.service.IRagDocumentService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import java.util.ArrayList; import java.util.List; +@Slf4j @Service public class RagDocumentServiceImpl extends ServiceImpl implements IRagDocumentService { + @Autowired + private ISysAttachmentService sysAttachmentService; + @Override public List listResponses() { - return toResponses(list()); + log.info("RagDocumentServiceImpl.listResponses start"); + List responses = toResponses(list()); + log.info("RagDocumentServiceImpl.listResponses success, count={}", responses.size()); + return responses; } @Override public List query(RagDocumentQueryRequest request) { - if (request == null) { - throw new IllegalArgumentException("查询请求不能为空"); - } - return toResponses(lambdaQuery() - .eq(request.getStoreId() != null, RagDocument::getStoreId, request.getStoreId()) - .eq(request.getAttachmentId() != null, RagDocument::getAttachmentId, request.getAttachmentId()) - .eq(request.getParseStatus() != null, RagDocument::getParseStatus, request.getParseStatus()) - .eq(request.getIndexStatus() != null, RagDocument::getIndexStatus, request.getIndexStatus()) - .eq(request.getEnabled() != null, RagDocument::getEnabled, request.getEnabled()) + log.info("RagDocumentServiceImpl.query start, request={}", request); + RagDocumentQueryRequest queryRequest = request == null ? new RagDocumentQueryRequest() : request; + String parseStatus = trimToNull(queryRequest.getParseStatus()); + String indexStatus = trimToNull(queryRequest.getIndexStatus()); + List responses = toResponses(lambdaQuery() + .eq(queryRequest.getStoreId() != null, RagDocument::getStoreId, queryRequest.getStoreId()) + .eq(queryRequest.getAttachmentId() != null, RagDocument::getAttachmentId, queryRequest.getAttachmentId()) + .eq(parseStatus != null, RagDocument::getParseStatus, parseStatus) + .eq(indexStatus != null, RagDocument::getIndexStatus, indexStatus) + .eq(queryRequest.getEnabled() != null, RagDocument::getEnabled, queryRequest.getEnabled()) .orderByDesc(RagDocument::getId) .list()); + log.info("RagDocumentServiceImpl.query success, count={}", responses.size()); + return responses; + } + + @Override + public RagDocumentResponse getResponseById(Long id) { + log.info("RagDocumentServiceImpl.getResponseById start, id={}", id); + RagDocumentResponse response = RagDocumentResponse.fromEntity(getById(id)); + log.info("RagDocumentServiceImpl.getResponseById success, id={}, found={}", id, response != null); + return response; + } + + @Override + public boolean saveOrUpdate(RagDocumentSaveRequest request) { + log.info("RagDocumentServiceImpl.saveOrUpdate start, request={}", request); + validateSaveRequest(request); + + RagDocument document; + if (request.getId() != null) { + document = getById(request.getId()); + if (document == null) { + log.warn("RagDocumentServiceImpl.saveOrUpdate document not found, id={}", request.getId()); + throw new IllegalArgumentException("文档不存在,ID: " + request.getId()); + } + } else { + document = new RagDocument(); + document.setEnabled(true); + document.setParseStatus(RagParseStatusEnum.UPLOADED.name()); + document.setIndexStatus(RagIndexStatusEnum.PENDING.name()); + } + + document.setStoreId(request.getStoreId()); + document.setAttachmentId(request.getAttachmentId()); + document.setDocumentTitle(request.getDocumentTitle().trim()); + document.setDocumentSummary(trimToNull(request.getDocumentSummary())); + if (StringUtils.hasText(request.getParseStatus())) { + document.setParseStatus(request.getParseStatus().trim()); + } + if (StringUtils.hasText(request.getIndexStatus())) { + document.setIndexStatus(request.getIndexStatus().trim()); + } + if (request.getEnabled() != null) { + document.setEnabled(request.getEnabled()); + } + document.setErrorMessage(trimToNull(request.getErrorMessage())); + document.setRemark(trimToNull(request.getRemark())); + + boolean result = saveOrUpdate(document); + log.info("RagDocumentServiceImpl.saveOrUpdate success, requestId={}, savedId={}, result={}", + request.getId(), document.getId(), result); + return result; + } + + @Override + public boolean removeById(Long id) { + log.info("RagDocumentServiceImpl.removeById start, id={}", id); + if (id == null) { + throw new IllegalArgumentException("文档ID不能为空"); + } + RagDocument document = getById(id); + if (document == null) { + log.warn("RagDocumentServiceImpl.removeById document not found, id={}", id); + throw new IllegalArgumentException("文档不存在,ID: " + id); + } + boolean result = super.removeById(id); + log.info("RagDocumentServiceImpl.removeById success, id={}, result={}", id, result); + return result; + } + + @Override + public List batchUpload(RagDocumentBatchUploadRequest request) { + log.info("RagDocumentServiceImpl.batchUpload start, request={}", request); + validateBatchUploadRequest(request); + + List results = new ArrayList<>(); + + for (var file : request.getFiles()) { + if (file == null || file.isEmpty()) { + continue; + } + + SysAttachmentUploadRequest uploadRequest = new SysAttachmentUploadRequest(); + uploadRequest.setFile(file); + uploadRequest.setSourceType(resolveSourceType(request.getSourceType())); + uploadRequest.setSourceId(request.getStoreId()); + + SysAttachment attachment = sysAttachmentService.upload(uploadRequest); + + RagDocument document = new RagDocument(); + document.setStoreId(request.getStoreId()); + document.setAttachmentId(attachment.getId()); + document.setDocumentTitle(StringUtils.hasText(file.getOriginalFilename()) ? file.getOriginalFilename().trim() : null); + document.setDocumentSummary(trimToNull(request.getDocumentSummary())); + document.setParseStatus(RagParseStatusEnum.UPLOADED.name()); + document.setIndexStatus(RagIndexStatusEnum.PENDING.name()); + document.setEnabled(true); + document.setErrorMessage(null); + document.setRemark(trimToNull(request.getRemark())); + + save(document); + results.add(RagDocumentResponse.fromEntity(document)); + } + + log.info("RagDocumentServiceImpl.batchUpload success, storeId={}, uploaded={}", + request.getStoreId(), results.size()); + return results; + } + + void validateSaveRequest(RagDocumentSaveRequest request) { + if (request == null) { + throw new IllegalArgumentException("保存请求不能为空"); + } + if (request.getStoreId() == null) { + throw new IllegalArgumentException("知识库ID不能为空"); + } + if (!StringUtils.hasText(request.getDocumentTitle())) { + throw new IllegalArgumentException("文档标题不能为空"); + } + } + + void validateBatchUploadRequest(RagDocumentBatchUploadRequest request) { + if (request == null) { + throw new IllegalArgumentException("上传请求不能为空"); + } + if (request.getStoreId() == null) { + throw new IllegalArgumentException("知识库ID不能为空"); + } + if (StringUtils.hasText(request.getSourceType()) + && !RagSystemConstants.SOURCE_TYPE_RAG.equals(request.getSourceType().trim())) { + throw new IllegalArgumentException("sourceType必须为RAG"); + } + if (request.getFiles() == null || request.getFiles().length == 0) { + throw new IllegalArgumentException("上传文件不能为空"); + } } private List toResponses(List documents) { @@ -38,4 +192,18 @@ public class RagDocumentServiceImpl extends ServiceImpl true).when(ragDocumentService).save(any(RagDocument.class)); + + var responses = ragDocumentService.batchUpload(request); + + ArgumentCaptor uploadCaptor = ArgumentCaptor.forClass(SysAttachmentUploadRequest.class); + verify(sysAttachmentService).upload(uploadCaptor.capture()); + SysAttachmentUploadRequest uploadRequest = uploadCaptor.getValue(); + assertEquals(RagSystemConstants.SOURCE_TYPE_RAG, uploadRequest.getSourceType()); + assertEquals(1001L, uploadRequest.getSourceId()); + assertEquals(file, uploadRequest.getFile()); + + ArgumentCaptor documentCaptor = ArgumentCaptor.forClass(RagDocument.class); + verify(ragDocumentService).save(documentCaptor.capture()); + RagDocument savedDocument = documentCaptor.getValue(); + assertEquals(1001L, savedDocument.getStoreId()); + assertEquals(2002L, savedDocument.getAttachmentId()); + assertEquals("knowledge.txt", savedDocument.getDocumentTitle()); + assertEquals("批量摘要", savedDocument.getDocumentSummary()); + assertEquals(RagParseStatusEnum.UPLOADED.name(), savedDocument.getParseStatus()); + assertEquals(RagIndexStatusEnum.PENDING.name(), savedDocument.getIndexStatus()); + assertTrue(savedDocument.getEnabled()); + assertNull(savedDocument.getErrorMessage()); + assertEquals("批量备注", savedDocument.getRemark()); + assertEquals(1, responses.size()); + assertEquals(RagParseStatusEnum.UPLOADED.name(), responses.getFirst().getParseStatus()); + assertEquals(RagIndexStatusEnum.PENDING.name(), responses.getFirst().getIndexStatus()); + } + + @Test + void saveOrUpdateShouldWriteAllEditableFields() { + RagDocument existingDocument = new RagDocument(); + existingDocument.setId(3003L); + + RagDocumentSaveRequest request = new RagDocumentSaveRequest(); + request.setId(3003L); + request.setStoreId(1001L); + request.setAttachmentId(2002L); + request.setDocumentTitle(" 新标题 "); + request.setDocumentSummary(" 新摘要 "); + request.setParseStatus(RagParseStatusEnum.PARSED.name()); + request.setIndexStatus(RagIndexStatusEnum.INDEXED.name()); + request.setEnabled(false); + request.setErrorMessage(" 已修复 "); + request.setRemark(" 备注信息 "); + + doReturn(existingDocument).when(ragDocumentService).getById(3003L); + doReturn(true).when(ragDocumentService).saveOrUpdate(any(RagDocument.class)); + + boolean result = ragDocumentService.saveOrUpdate(request); + + assertTrue(result); + ArgumentCaptor documentCaptor = ArgumentCaptor.forClass(RagDocument.class); + verify(ragDocumentService).saveOrUpdate(documentCaptor.capture()); + RagDocument savedDocument = documentCaptor.getValue(); + assertEquals(3003L, savedDocument.getId()); + assertEquals(1001L, savedDocument.getStoreId()); + assertEquals(2002L, savedDocument.getAttachmentId()); + assertEquals("新标题", savedDocument.getDocumentTitle()); + assertEquals("新摘要", savedDocument.getDocumentSummary()); + assertEquals(RagParseStatusEnum.PARSED.name(), savedDocument.getParseStatus()); + assertEquals(RagIndexStatusEnum.INDEXED.name(), savedDocument.getIndexStatus()); + assertEquals(false, savedDocument.getEnabled()); + assertEquals("已修复", savedDocument.getErrorMessage()); + assertEquals("备注信息", savedDocument.getRemark()); + } +}