feat(rag-document): 补全文档管理接口与页面

This commit is contained in:
zhiye.sun
2026-05-21 15:34:12 +08:00
parent 67cfbeb572
commit 541c3ff455
12 changed files with 1233 additions and 15 deletions

View File

@@ -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);
});
});

View File

@@ -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<RagDocument[]>('/rag/documents/list');
}
export function queryRagDocuments(query?: RagDocumentQueryRequest) {
return post<RagDocument[], RagDocumentQueryRequest | undefined>('/rag/documents/query', query);
}
export function getRagDocumentById(id: string) {
return get<RagDocument>('/rag/documents/detail', {
params: { id },
});
}
export function saveRagDocument(data: RagDocumentSaveRequest) {
return post<boolean, RagDocumentSaveRequest>('/rag/documents/save', data);
}
export function deleteRagDocument(id: string) {
return post<boolean>('/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<RagDocument[], FormData>('/rag/documents/batchUpload', formData);
}

View File

@@ -1,8 +1,478 @@
<script setup lang="ts">
import { Search, UploadFilled } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, reactive, ref } from 'vue';
import {
batchUploadRagDocuments,
deleteRagDocument,
getRagDocumentById,
listRagDocuments,
saveRagDocument,
SOURCE_TYPE_RAG,
type RagDocument,
} from '@/api/ragDocuments';
import { queryRagStores, type RagStore } from '@/api/ragStores';
const loading = ref(false);
const submitting = ref(false);
const storeOptions = ref<RagStore[]>([]);
const docRows = ref<RagDocument[]>([]);
const queryForm = reactive({
storeId: '',
parseStatus: '',
indexStatus: '',
enabled: '' as string,
keyword: '',
});
const editDialogVisible = ref(false);
const uploadDialogVisible = ref(false);
const uploadStoreId = ref('');
const uploadSummary = ref('');
const uploadRemark = ref('');
const editForm = reactive({
id: '',
storeId: '',
attachmentId: '',
documentTitle: '',
documentSummary: '',
enabled: true,
remark: '',
});
const filteredRows = computed(() => {
const kw = queryForm.keyword.trim().toLowerCase();
return docRows.value.filter(
(row) => {
const matchStore = !queryForm.storeId || row.storeId === queryForm.storeId;
const matchParseStatus = !queryForm.parseStatus || row.parseStatus === queryForm.parseStatus;
const matchIndexStatus = !queryForm.indexStatus || row.indexStatus === queryForm.indexStatus;
const matchEnabled = !queryForm.enabled || String(row.enabled ?? false) === queryForm.enabled;
const matchKeyword =
!kw ||
(row.documentTitle && row.documentTitle.toLowerCase().includes(kw)) ||
(row.documentSummary && row.documentSummary.toLowerCase().includes(kw)) ||
(row.remark && row.remark.toLowerCase().includes(kw));
return matchStore && matchParseStatus && matchIndexStatus && matchEnabled && matchKeyword;
},
);
});
async function loadStores() {
try {
const response = await queryRagStores();
storeOptions.value = response.data ?? [];
} catch {
storeOptions.value = [];
}
}
async function loadDocs() {
loading.value = true;
try {
const response = await listRagDocuments();
docRows.value = response.data ?? [];
} finally {
loading.value = false;
}
}
function handleSearch() {
loadDocs();
}
function handleReset() {
queryForm.storeId = '';
queryForm.parseStatus = '';
queryForm.indexStatus = '';
queryForm.enabled = '';
queryForm.keyword = '';
loadDocs();
}
function openUploadDialog() {
if (storeOptions.value.length === 0) {
ElMessage.warning('请先创建知识库');
return;
}
const firstStore = storeOptions.value[0];
uploadStoreId.value = queryForm.storeId || (firstStore ? String(firstStore.id) : '');
uploadSummary.value = '';
uploadRemark.value = '';
uploadDialogVisible.value = true;
}
async function submitUpload() {
if (!uploadStoreId.value) {
ElMessage.warning('请选择知识库');
return;
}
const files = (document.getElementById('rag-file-input') as HTMLInputElement)?.files;
if (!files || files.length === 0) {
ElMessage.warning('请选择要上传的文件');
return;
}
submitting.value = true;
try {
await batchUploadRagDocuments({
storeId: uploadStoreId.value,
sourceType: SOURCE_TYPE_RAG,
files: Array.from(files),
documentSummary: uploadSummary.value || undefined,
remark: uploadRemark.value || undefined,
});
uploadDialogVisible.value = false;
ElMessage.success('文档已上传');
await loadDocs();
} finally {
submitting.value = false;
}
}
async function openEditDialog(row: RagDocument) {
const detail = row.id ? (await getRagDocumentById(String(row.id))).data : row;
editForm.id = String(detail.id ?? '');
editForm.storeId = detail.storeId;
editForm.attachmentId = detail.attachmentId ?? '';
editForm.documentTitle = detail.documentTitle ?? '';
editForm.documentSummary = detail.documentSummary ?? '';
editForm.enabled = detail.enabled ?? true;
editForm.remark = detail.remark ?? '';
editDialogVisible.value = true;
}
async function submitEdit() {
if (!editForm.id || !editForm.storeId || !editForm.documentTitle) {
ElMessage.warning('请填写文档标题');
return;
}
submitting.value = true;
try {
await saveRagDocument({
id: editForm.id,
storeId: editForm.storeId,
attachmentId: editForm.attachmentId || undefined,
documentTitle: editForm.documentTitle,
documentSummary: editForm.documentSummary || undefined,
enabled: editForm.enabled,
remark: editForm.remark || undefined,
});
editDialogVisible.value = false;
ElMessage.success('文档信息已更新');
await loadDocs();
} finally {
submitting.value = false;
}
}
async function removeDoc(row: RagDocument) {
if (!row.id) return;
await ElMessageBox.confirm(
`确认删除文档「${row.documentTitle || '未命名'}」?`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
);
await deleteRagDocument(String(row.id));
ElMessage.success('文档已删除');
await loadDocs();
}
function toggleEnabled(row: RagDocument) {
if (!row.id) return;
const newEnabled = !row.enabled;
saveRagDocument({
id: String(row.id),
storeId: row.storeId,
documentTitle: row.documentTitle ?? '',
enabled: newEnabled,
}).then(() => {
row.enabled = newEnabled;
ElMessage.success(`${newEnabled ? '启用' : '停用'}`);
});
}
function getStoreName(storeId: string) {
const store = storeOptions.value.find((s) => String(s.id) === storeId);
return store ? store.storeName : '-';
}
function getStatusLabel(status?: string | null) {
const map: Record<string, string> = {
UPLOADED: '已上传',
PARSING: '解析中',
PARSED: '已解析',
FAILED: '解析失败',
PENDING: '待索引',
INDEXING: '索引中',
INDEXED: '已索引',
};
return status ? (map[status] ?? status) : '-';
}
function getParseStatusType(status?: string | null) {
const success = ['PARSED'];
const warning = ['UPLOADED', 'PARSING'];
const danger = ['FAILED'];
if (!status) return 'info';
if (success.includes(status)) return 'success';
if (warning.includes(status)) return 'warning';
if (danger.includes(status)) return 'danger';
return 'info';
}
function getIndexStatusType(status?: string | null) {
const success = ['INDEXED'];
const warning = ['PENDING', 'INDEXING'];
const danger = ['FAILED'];
if (!status) return 'info';
if (success.includes(status)) return 'success';
if (warning.includes(status)) return 'warning';
if (danger.includes(status)) return 'danger';
return 'info';
}
onMounted(() => {
loadStores();
loadDocs();
});
</script>
<template>
<section class="page-panel">
<section class="page-panel rag-doc-page">
<div class="page-panel__header">
<h2>知识文档</h2>
<span>Documents</span>
</div>
<div class="toolbar">
<div class="toolbar__filters">
<el-select
v-model="queryForm.storeId"
data-test="doc-store-filter"
placeholder="选择知识库"
clearable
style="width: 180px"
>
<el-option
v-for="store in storeOptions"
:key="String(store.id)"
:label="store.storeName"
:value="String(store.id)"
/>
</el-select>
<el-select
v-model="queryForm.parseStatus"
data-test="doc-parse-filter"
placeholder="解析状态"
clearable
style="width: 130px"
>
<el-option label="已上传" value="UPLOADED" />
<el-option label="解析中" value="PARSING" />
<el-option label="已解析" value="PARSED" />
<el-option label="解析失败" value="FAILED" />
</el-select>
<el-select
v-model="queryForm.indexStatus"
data-test="doc-index-filter"
placeholder="索引状态"
clearable
style="width: 130px"
>
<el-option label="待索引" value="PENDING" />
<el-option label="索引中" value="INDEXING" />
<el-option label="已索引" value="INDEXED" />
<el-option label="索引失败" value="FAILED" />
</el-select>
<el-select
v-model="queryForm.enabled"
data-test="doc-enabled-filter"
placeholder="启用状态"
clearable
style="width: 120px"
>
<el-option label="启用" value="true" />
<el-option label="停用" value="false" />
</el-select>
<el-input
v-model="queryForm.keyword"
data-test="doc-keyword-input"
placeholder="搜索标题/摘要/备注"
clearable
style="width: 180px"
@keyup.enter="handleSearch"
/>
</div>
<div class="toolbar__actions">
<el-button type="primary" :icon="UploadFilled" @click="openUploadDialog">批量上传</el-button>
<el-button data-test="doc-search" type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
</div>
<el-table v-loading="loading" :data="filteredRows" border stripe style="width: 100%">
<el-table-column prop="documentTitle" label="文档标题" min-width="180" show-overflow-tooltip />
<el-table-column label="所属知识库" width="150">
<template #default="{ row }">
{{ getStoreName(row.storeId) }}
</template>
</el-table-column>
<el-table-column label="解析状态" width="110">
<template #default="{ row }">
<el-tag :type="getParseStatusType(row.parseStatus)" size="small">
{{ getStatusLabel(row.parseStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="索引状态" width="110">
<template #default="{ row }">
<el-tag :type="getIndexStatusType(row.indexStatus)" size="small">
{{ getStatusLabel(row.indexStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" width="80" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.enabled ?? false"
size="small"
@change="toggleEnabled(row)"
/>
</template>
</el-table-column>
<el-table-column prop="documentSummary" label="摘要" min-width="160" show-overflow-tooltip />
<el-table-column prop="createTime" label="创建时间" width="170" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button :data-test="`doc-edit-${row.id}`" link type="primary" @click="openEditDialog(row)">编辑</el-button>
<el-button link type="danger" @click="removeDoc(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && filteredRows.length === 0" description="暂无知识文档" />
<!-- 上传对话框 -->
<el-dialog v-model="uploadDialogVisible" title="批量上传文档" width="560px">
<el-form label-width="96px">
<el-form-item label="知识库" required>
<el-select v-model="uploadStoreId" placeholder="请选择知识库" style="width: 100%">
<el-option
v-for="store in storeOptions"
:key="String(store.id)"
:label="store.storeName"
:value="String(store.id)"
/>
</el-select>
</el-form-item>
<el-form-item label="选择文件" required>
<input id="rag-file-input" type="file" multiple accept=".pdf,.doc,.docx,.txt,.md" />
<p class="form-hint">支持 PDFWordTXTMarkdown 等格式</p>
</el-form-item>
<el-form-item label="文档摘要">
<el-input
v-model="uploadSummary"
type="textarea"
:rows="2"
placeholder="可选,将统一设置到所有上传文档"
/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="uploadRemark"
type="textarea"
:rows="2"
placeholder="可选"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitUpload">上传</el-button>
</template>
</el-dialog>
<!-- 编辑对话框 -->
<el-dialog v-model="editDialogVisible" title="编辑文档" width="560px">
<el-form :model="editForm" label-width="96px">
<el-form-item label="文档标题" required>
<el-input v-model="editForm.documentTitle" />
</el-form-item>
<el-form-item label="文档摘要">
<el-input v-model="editForm.documentSummary" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="editForm.enabled" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.rag-doc-page {
display: flex;
flex-direction: column;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 16px 22px;
border-bottom: 1px solid #eef2f7;
flex-wrap: wrap;
}
.toolbar__filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
flex: 1;
}
.toolbar__actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.form-hint {
margin: 4px 0 0;
color: #98a2b3;
font-size: 12px;
}
@media (max-width: 768px) {
.toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar__filters {
flex-direction: column;
}
.toolbar__actions {
justify-content: flex-end;
}
}
</style>

View File

@@ -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');
});
});

View File

@@ -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() {
}
}

View File

@@ -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<RagDocumentResponse>> list() {
return RequestResult.success(ragDocumentService.listResponses());
log.info("RagDocumentController.list start");
List<RagDocumentResponse> responses = ragDocumentService.listResponses();
log.info("RagDocumentController.list success, count={}", responses.size());
return RequestResult.success(responses);
}
@Operation(summary = "按条件查询知识库文档")
@PostMapping("/query")
public RequestResult<List<RagDocumentResponse>> query(@RequestBody RagDocumentQueryRequest request) {
return RequestResult.success(ragDocumentService.query(request));
public RequestResult<List<RagDocumentResponse>> query(@RequestBody(required = false) RagDocumentQueryRequest request) {
log.info("RagDocumentController.query start, request={}", request);
List<RagDocumentResponse> responses = ragDocumentService.query(request);
log.info("RagDocumentController.query success, count={}", responses.size());
return RequestResult.success(responses);
}
@Operation(summary = "查询知识库文档详情")
@GetMapping("/detail")
public RequestResult<RagDocumentResponse> 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<Boolean> 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<Boolean> 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<List<RagDocumentResponse>> batchUpload(@ModelAttribute RagDocumentBatchUploadRequest request) {
log.info("RagDocumentController.batchUpload start, storeId={}, fileCount={}",
request.getStoreId(), request.getFiles() != null ? request.getFiles().length : 0);
List<RagDocumentResponse> responses = ragDocumentService.batchUpload(request);
log.info("RagDocumentController.batchUpload success, storeId={}, uploaded={}",
request.getStoreId(), responses.size());
return RequestResult.success(responses);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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<RagDocument> {
List<RagDocumentResponse> listResponses();
List<RagDocumentResponse> query(RagDocumentQueryRequest request);
RagDocumentResponse getResponseById(Long id);
boolean saveOrUpdate(RagDocumentSaveRequest request);
boolean removeById(Long id);
List<RagDocumentResponse> batchUpload(RagDocumentBatchUploadRequest request);
}

View File

@@ -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<RagDocumentMapper, RagDocument> implements IRagDocumentService {
@Autowired
private ISysAttachmentService sysAttachmentService;
@Override
public List<RagDocumentResponse> listResponses() {
return toResponses(list());
log.info("RagDocumentServiceImpl.listResponses start");
List<RagDocumentResponse> responses = toResponses(list());
log.info("RagDocumentServiceImpl.listResponses success, count={}", responses.size());
return responses;
}
@Override
public List<RagDocumentResponse> 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<RagDocumentResponse> 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<RagDocumentResponse> batchUpload(RagDocumentBatchUploadRequest request) {
log.info("RagDocumentServiceImpl.batchUpload start, request={}", request);
validateBatchUploadRequest(request);
List<RagDocumentResponse> 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<RagDocumentResponse> toResponses(List<RagDocument> documents) {
@@ -38,4 +192,18 @@ public class RagDocumentServiceImpl extends ServiceImpl<RagDocumentMapper, RagDo
.map(RagDocumentResponse::fromEntity)
.toList();
}
private String resolveSourceType(String sourceType) {
if (!StringUtils.hasText(sourceType)) {
return RagSystemConstants.SOURCE_TYPE_RAG;
}
return RagSystemConstants.SOURCE_TYPE_RAG;
}
private String trimToNull(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim();
}
}

View File

@@ -0,0 +1,124 @@
package com.bruce.rag;
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.RagDocumentSaveRequest;
import com.bruce.rag.entity.RagDocument;
import com.bruce.rag.enums.RagIndexStatusEnum;
import com.bruce.rag.enums.RagParseStatusEnum;
import com.bruce.rag.service.impl.RagDocumentServiceImpl;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class RagDocumentServiceImplTests {
@Spy
@InjectMocks
private RagDocumentServiceImpl ragDocumentService;
@Mock
private ISysAttachmentService sysAttachmentService;
@Test
void batchUploadShouldUseRagSourceTypeAndStoreIdAsSourceId() {
MockMultipartFile file = new MockMultipartFile(
"files",
"knowledge.txt",
"text/plain",
"hello rag".getBytes()
);
RagDocumentBatchUploadRequest request = new RagDocumentBatchUploadRequest();
request.setStoreId(1001L);
request.setSourceType(RagSystemConstants.SOURCE_TYPE_RAG);
request.setFiles(new MockMultipartFile[]{file});
request.setDocumentSummary("批量摘要");
request.setRemark("批量备注");
SysAttachment attachment = new SysAttachment();
attachment.setId(2002L);
when(sysAttachmentService.upload(any(SysAttachmentUploadRequest.class))).thenReturn(attachment);
doAnswer(invocation -> true).when(ragDocumentService).save(any(RagDocument.class));
var responses = ragDocumentService.batchUpload(request);
ArgumentCaptor<SysAttachmentUploadRequest> 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<RagDocument> 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<RagDocument> 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());
}
}