Browse Source

feat(document): 新增知识库文档目录(文件夹)功能

- 新增 KnowledgeFolder 实体/Mapper、FolderService、FolderController,支持目录树增删改查
- 文档新增 folderId 字段,列表支持按目录筛选与服务端排序
- 新增文件夹批量上传(按相对路径自动创建子目录)
- 文档列表页新增左侧目录树面板,上传页新增目录选择与文件夹上传
Spring-AI-1.1.2
wanghanlin 2 weeks ago
parent
commit
780917561b
  1. 2
      frontend/components.d.ts
  2. 5
      frontend/src/api/document.ts
  3. 17
      frontend/src/api/folder.ts
  4. 5
      frontend/src/api/upload.ts
  5. 15
      frontend/src/types/models.ts
  6. 404
      frontend/src/views/DocList.vue
  7. 169
      frontend/src/views/DocUpload.vue
  8. 59
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  9. 59
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  10. 136
      src/main/java/com/wok/supportbot/controller/FolderController.java
  11. 12
      src/main/java/com/wok/supportbot/dao/KnowledgeFolderMapper.java
  12. 63
      src/main/java/com/wok/supportbot/entity/FolderNode.java
  13. 7
      src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java
  14. 80
      src/main/java/com/wok/supportbot/entity/KnowledgeFolder.java
  15. 205
      src/main/java/com/wok/supportbot/service/DocumentService.java
  16. 313
      src/main/java/com/wok/supportbot/service/FolderService.java
  17. 36
      src/main/resources/init-database.sql

2
frontend/components.d.ts

@ -47,6 +47,8 @@ declare module 'vue' {
TTextarea: typeof import('tdesign-vue-next')['Textarea'] TTextarea: typeof import('tdesign-vue-next')['Textarea']
TTimeline: typeof import('tdesign-vue-next')['Timeline'] TTimeline: typeof import('tdesign-vue-next')['Timeline']
TTimelineItem: typeof import('tdesign-vue-next')['TimelineItem'] TTimelineItem: typeof import('tdesign-vue-next')['TimelineItem']
TTree: typeof import('tdesign-vue-next')['Tree']
TTreeSelect: typeof import('tdesign-vue-next')['TreeSelect']
TUpload: typeof import('tdesign-vue-next')['Upload'] TUpload: typeof import('tdesign-vue-next')['Upload']
} }
} }

5
frontend/src/api/document.ts

@ -12,12 +12,15 @@ function authHeaders(): Record<string, string> {
// ==================== 文档 CRUD ==================== // ==================== 文档 CRUD ====================
/** 文档列表(分页 + 过滤 + 搜索) */ /** 文档列表(分页 + 过滤 + 搜索) */
export function listDocuments(page = 1, size = 10, categoryId?: string, status?: string, keyword?: string, tag?: string): Promise<ApiResponse> {
export function listDocuments(page = 1, size = 10, categoryId?: string, status?: string, keyword?: string, tag?: string, folderId?: string, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/document/list?page=${page}&size=${size}` let path = `/document/list?page=${page}&size=${size}`
if (categoryId) path += `&categoryId=${categoryId}` if (categoryId) path += `&categoryId=${categoryId}`
if (folderId != null) path += `&folderId=${folderId}`
if (status) path += `&status=${status}` if (status) path += `&status=${status}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (tag) path += `&tag=${encodeURIComponent(tag)}` if (tag) path += `&tag=${encodeURIComponent(tag)}`
if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}`
return request.get(path).then(r => r.data) return request.get(path).then(r => r.data)
} }

17
frontend/src/api/folder.ts

@ -0,0 +1,17 @@
import request from './request'
import type { ApiResponse } from '@/types/api'
/** 获取目录树(可按分类过滤) */
export function getFolderTree(categoryId?: string): Promise<ApiResponse> { return request.get(`/folder/tree${categoryId ? `?categoryId=${categoryId}` : ''}`).then(r => r.data) }
/** 获取目录列表(可按分类过滤) */
export function getFolderList(categoryId?: string): Promise<ApiResponse> { return request.get(`/folder/list${categoryId ? `?categoryId=${categoryId}` : ''}`).then(r => r.data) }
/** 创建目录 */
export function createFolder(data: any): Promise<ApiResponse> { return request.post('/folder', data).then(r => r.data) }
/** 更新目录 */
export function updateFolder(id: string, data: any): Promise<ApiResponse> { return request.put(`/folder/${id}`, data).then(r => r.data) }
/** 删除目录 */
export function deleteFolder(id: string): Promise<ApiResponse> { return request.delete(`/folder/${id}`).then(r => r.data) }

5
frontend/src/api/upload.ts

@ -24,6 +24,11 @@ export function uploadFile(formData: FormData, onProgress?: (pct: number) => voi
return postFormWithProgress('/upload/file', formData, onProgress) return postFormWithProgress('/upload/file', formData, onProgress)
} }
/** 上传文件夹(批量文件 + 相对路径,复制目录结构) */
export function uploadFolder(formData: FormData, onProgress?: (pct: number) => void): Promise<ApiResponse> {
return postFormWithProgress('/upload/folder', formData, onProgress)
}
/** 上传 Markdown(带进度) */ /** 上传 Markdown(带进度) */
export function uploadMarkdown(formData: FormData, onProgress?: (pct: number) => void): Promise<ApiResponse> { export function uploadMarkdown(formData: FormData, onProgress?: (pct: number) => void): Promise<ApiResponse> {
return postFormWithProgress('/upload/markdown', formData, onProgress) return postFormWithProgress('/upload/markdown', formData, onProgress)

15
frontend/src/types/models.ts

@ -30,6 +30,7 @@ export interface KnowledgeDocument {
id: string id: string
title: string title: string
categoryId: string categoryId: string
folderId?: string
categoryName?: string categoryName?: string
tags?: string[] tags?: string[]
status: 'processing' | 'ready' | 'error' status: 'processing' | 'ready' | 'error'
@ -62,6 +63,20 @@ export interface KnowledgeCategory {
children?: KnowledgeCategory[] children?: KnowledgeCategory[]
} }
/** 知识库目录(文件夹) */
export interface KnowledgeFolder {
id: string
name: string
parentId: string
categoryId: string
sortOrder?: number
documentCount?: number
children?: KnowledgeFolder[]
}
/** 目录节点(目录树中的节点,结构与 KnowledgeFolder 一致) */
export type FolderNode = KnowledgeFolder
// ==================== FAQ 管理 ==================== // ==================== FAQ 管理 ====================
/** FAQ 条目 */ /** FAQ 条目 */

404
frontend/src/views/DocList.vue

@ -1,70 +1,129 @@
<template> <template>
<t-card title="文档列表" :bordered="false"> <t-card title="文档列表" :bordered="false">
<!-- 工具栏搜索 + 筛选 + 批量操作 -->
<div class="toolbar">
<t-input v-model="keyword" placeholder="搜索文档标题或文件名..." clearable @change="debouncedLoad" style="max-width:260px;" size="small" />
<t-select v-model="filterCategory" :options="categoryOptions" placeholder="全部分类" clearable size="small" style="width:140px;" @change="load()" />
<t-select v-model="filterStatus" :options="statusOptions" placeholder="全部状态" clearable size="small" style="width:110px;" @change="load()" />
<t-select v-model="filterTag" :options="tagOptions" placeholder="全部标签" clearable size="small" style="width:140px;" @change="load()" />
<t-button variant="outline" size="small" @click="load()">刷新</t-button>
<t-tag v-if="hasProcessing" theme="warning" size="small"> 有文档处理中...</t-tag>
<template v-if="selectedIds.size > 0">
<t-divider layout="vertical" />
<span class="selected-count">已选 {{ selectedIds.size }} </span>
<t-button theme="danger" size="small" @click="batchRemove">批量删除</t-button>
<t-button variant="outline" size="small" @click="batchReprocess">批量重新处理</t-button>
<t-button variant="outline" size="small" @click="batchEnable">批量启用</t-button>
<t-button variant="outline" size="small" @click="batchDisable">批量禁用</t-button>
<t-select v-model="moveCategoryId" :options="moveCategoryOptions" placeholder="移动到分类..." size="small" style="width:140px;" />
<t-button variant="outline" size="small" @click="batchMove" :disabled="moveCategoryId === ''">移动</t-button>
<t-button variant="text" size="small" @click="clearSelection">取消选择</t-button>
</template>
</div>
<div class="doc-layout">
<!-- 左侧目录树面板 -->
<aside class="doc-tree-panel">
<div class="tree-panel-header">
<span class="tree-panel-title">📁 文档目录</span>
<t-button variant="text" size="small" @click="loadTreeData">刷新</t-button>
</div>
<div v-if="folderTreeLoading" class="tree-loading">加载中...</div>
<t-tree v-else
:data="treeData"
:keys="{ value: 'value', label: 'label', children: 'children' }"
:activable="true"
:active-multiple="false"
hover
expand-all
empty="暂无分类与目录"
v-model:actived="activedKeys"
@active="onTreeActive"
>
<template #label="{ node }">
<span class="tree-node">
<span class="tree-label-text">{{ node.data.label }}</span>
<!-- 目录节点 hover 时显示 /重命名/删除 -->
<span v-if="node.data.nodeType === 'folder'" class="tree-ops">
<t-button size="small" variant="text" @click.stop="openCreateFolder(node.data.categoryId, node.data.folderId)"></t-button>
<t-button size="small" variant="text" @click.stop="openRenameFolder(node.data)">重命名</t-button>
<t-button size="small" variant="text" theme="danger" @click.stop="removeFolder(node.data)">删除</t-button>
</span>
<!-- 分类节点显示新建目录 -->
<span v-else class="tree-ops">
<t-button size="small" variant="text" @click.stop="openCreateFolder(node.data.categoryId)">新建目录</t-button>
</span>
</span>
</template>
</t-tree>
</aside>
<!-- 右侧工具栏 + 表格 -->
<div class="doc-content">
<!-- 工具栏搜索 + 筛选 + 批量操作 -->
<div class="toolbar">
<t-input v-model="keyword" placeholder="搜索文档标题或文件名..." clearable @change="debouncedLoad" style="max-width:260px;" size="small" />
<t-select v-model="filterCategory" :options="categoryOptions" placeholder="全部分类" clearable size="small" style="width:140px;" @change="onCategoryChange" />
<t-select v-model="filterStatus" :options="statusOptions" placeholder="全部状态" clearable size="small" style="width:110px;" @change="load()" />
<t-select v-model="filterTag" :options="tagOptions" placeholder="全部标签" clearable size="small" style="width:140px;" @change="load()" />
<t-button variant="outline" size="small" @click="load()">刷新</t-button>
<t-tag v-if="hasProcessing" theme="warning" size="small"> 有文档处理中...</t-tag>
<t-tag v-if="filterFolder" theme="primary" variant="light" size="small" :closable="true" @close="clearFolderFilter">📁 目录筛选</t-tag>
<!-- 表格 -->
<t-table
:data="documents" :columns="columns" row-key="id" :loading="loading"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }"
:selected-row-keys="Array.from(selectedIds)"
@page-change="onPageChange"
@select-change="onSelectChange"
>
<template #title="{ row }">
<div><strong>{{ row.title }}</strong></div>
<div style="font-size:11px;color:var(--td-text-color-placeholder);">{{ row.sourceName || '' }}</div>
<div v-if="getDocTags(row).length" style="margin-top:2px;">
<t-tag v-for="tag in getDocTags(row)" :key="tag" size="small" variant="light" theme="primary" style="margin-right:2px;">{{ tag }}</t-tag>
<template v-if="selectedIds.size > 0">
<t-divider layout="vertical" />
<span class="selected-count">已选 {{ selectedIds.size }} </span>
<t-button theme="danger" size="small" @click="batchRemove">批量删除</t-button>
<t-button variant="outline" size="small" @click="batchReprocess">批量重新处理</t-button>
<t-button variant="outline" size="small" @click="batchEnable">批量启用</t-button>
<t-button variant="outline" size="small" @click="batchDisable">批量禁用</t-button>
<t-select v-model="moveCategoryId" :options="moveCategoryOptions" placeholder="移动到分类..." size="small" style="width:140px;" />
<t-button variant="outline" size="small" @click="batchMove" :disabled="moveCategoryId === ''">移动</t-button>
<t-button variant="text" size="small" @click="clearSelection">取消选择</t-button>
</template>
</div> </div>
</template>
<template #fileType="{ row }"><t-tag size="small" variant="light">{{ row.fileType }}</t-tag></template>
<template #fileSize="{ row }"><span style="white-space:nowrap;">{{ formatSize(row.fileSize) }}</span></template>
<template #categoryId="{ row }"><span>{{ categoryStore.getCategoryName(row.categoryId) }}</span></template>
<template #status="{ row }">
<t-tag size="small" :theme="row.status === 'READY' ? 'success' : row.status === 'PROCESSING' ? 'warning' : 'danger'"
variant="light">{{ statusLabel(row.status) }}</t-tag>
</template>
<template #enabled="{ row }">
<t-switch v-model="row._enabled" size="small" @change="toggle(row)" />
</template>
<template #op="{ row }">
<t-space :size="2">
<t-button size="small" variant="text" @click="viewDetail(row.id)">查看</t-button>
<t-button v-show="row.filePath" size="small" variant="text" @click="download(row.id)">下载</t-button>
<t-button size="small" variant="text" @click="reprocess(row.id)">重新处理</t-button>
<t-button size="small" variant="text" theme="danger" @click="remove(row.id)">删除</t-button>
</t-space>
</template>
</t-table>
<!-- 表格 -->
<t-table
:data="documents" :columns="columns" row-key="id" :loading="loading"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }"
:selected-row-keys="Array.from(selectedIds)"
:sort="sortInfo"
@page-change="onPageChange"
@select-change="onSelectChange"
@sort-change="onSortChange"
>
<template #title="{ row }">
<div><strong>{{ row.title }}</strong></div>
<div style="font-size:11px;color:var(--td-text-color-placeholder);">{{ row.sourceName || '' }}</div>
<div v-if="getDocTags(row).length" style="margin-top:2px;">
<t-tag v-for="tag in getDocTags(row)" :key="tag" size="small" variant="light" theme="primary" style="margin-right:2px;">{{ tag }}</t-tag>
</div>
</template>
<template #fileType="{ row }"><t-tag size="small" variant="light">{{ row.fileType }}</t-tag></template>
<template #fileSize="{ row }"><span style="white-space:nowrap;">{{ formatSize(row.fileSize) }}</span></template>
<template #categoryId="{ row }"><span>{{ categoryStore.getCategoryName(row.categoryId) }}</span></template>
<template #status="{ row }">
<t-tag size="small" :theme="row.status === 'READY' ? 'success' : row.status === 'PROCESSING' ? 'warning' : 'danger'"
variant="light">{{ statusLabel(row.status) }}</t-tag>
</template>
<template #enabled="{ row }">
<t-switch v-model="row._enabled" size="small" @change="toggle(row)" />
</template>
<template #op="{ row }">
<t-space :size="2">
<t-button size="small" variant="text" @click="viewDetail(row.id)">查看</t-button>
<t-button v-show="row.filePath" size="small" variant="text" @click="download(row.id)">下载</t-button>
<t-button size="small" variant="text" @click="reprocess(row.id)">重新处理</t-button>
<t-button size="small" variant="text" theme="danger" @click="remove(row.id)">删除</t-button>
</t-space>
</template>
</t-table>
</div>
</div>
<!-- 目录创建 / 重命名弹窗 -->
<t-dialog v-model:visible="folderDialog.visible" :header="folderDialog.title" width="420px" :footer="false">
<t-form label-align="top">
<t-form-item label="目录名称">
<t-input v-model="folderDialog.name" placeholder="请输入目录名称" @enter="submitFolderDialog" />
</t-form-item>
</t-form>
<div class="dialog-footer">
<t-button variant="outline" @click="folderDialog.visible = false">取消</t-button>
<t-button theme="primary" @click="submitFolderDialog">保存</t-button>
</div>
</t-dialog>
</t-card> </t-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useDocumentStore } from '@/stores/document' import { useDocumentStore } from '@/stores/document'
import { useCategoryStore } from '@/stores/category' import { useCategoryStore } from '@/stores/category'
import { useDialogStore } from '@/stores/dialog' import { useDialogStore } from '@/stores/dialog'
import { listDocuments, deleteDocument, reprocessDocument, batchDeleteDocuments, batchReprocessDocuments, toggleDocument, batchToggleDocuments, batchMoveDocuments, downloadDocument } from '@/api/document' import { listDocuments, deleteDocument, reprocessDocument, batchDeleteDocuments, batchReprocessDocuments, toggleDocument, batchToggleDocuments, batchMoveDocuments, downloadDocument } from '@/api/document'
import { getFolderList, createFolder, updateFolder, deleteFolder } from '@/api/folder'
import { getCategoryTree } from '@/api/category'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { formatBytes, formatDate } from '@/utils/format' import { formatBytes, formatDate } from '@/utils/format'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
@ -91,6 +150,25 @@ const selectedIds = ref(new Set<string>())
const moveCategoryId = ref('') const moveCategoryId = ref('')
const hasProcessing = ref(false) const hasProcessing = ref(false)
//
const treeData = ref<any[]>([])
const activedKeys = ref<string[]>([])
const filterFolder = ref('')
const folderTreeLoading = ref(false)
//
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
// /
const folderDialog = ref({
visible: false,
title: '',
name: '',
mode: 'create' as 'create' | 'rename',
categoryId: '',
parentId: '',
folderId: '',
})
// ==================== ==================== // ==================== ====================
let pollTimer: any = null let pollTimer: any = null
@ -117,14 +195,14 @@ onUnmounted(() => { stopPolling() })
// ==================== ==================== // ==================== ====================
const columns = [ const columns = [
{ colKey: 'row-select', type: 'multiple', width: 50 }, { colKey: 'row-select', type: 'multiple', width: 50 },
{ colKey: 'title', title: '标题', width: 220 },
{ colKey: 'fileType', title: '类型', width: 70 },
{ colKey: 'fileSize', title: '大小', width: 80 },
{ colKey: 'title', title: '标题', width: 220, sorter: true },
{ colKey: 'fileType', title: '类型', width: 70, sorter: true },
{ colKey: 'fileSize', title: '大小', width: 80, sorter: true },
{ colKey: 'categoryId', title: '分类', width: 100 }, { colKey: 'categoryId', title: '分类', width: 100 },
{ colKey: 'status', title: '状态', width: 80 }, { colKey: 'status', title: '状态', width: 80 },
{ colKey: 'enabled', title: '启用', width: 60 }, { colKey: 'enabled', title: '启用', width: 60 },
{ colKey: 'chunkCount', title: '分块', width: 60 },
{ colKey: 'createTime', title: '创建时间', width: 160, cell: (_:any,{row}:any) => formatDate(row.createTime) },
{ colKey: 'chunkCount', title: '分块', width: 60, sorter: true },
{ colKey: 'createTime', title: '创建时间', width: 160, sorter: true, cell: (_:any,{row}:any) => formatDate(row.createTime) },
{ colKey: 'op', title: '操作', width: 200 }, { colKey: 'op', title: '操作', width: 200 },
] ]
@ -148,12 +226,192 @@ function formatSize(b: number) { return b ? formatBytes(b) : '-' }
// ==================== ==================== // ==================== ====================
const debouncedLoad = debounce(() => load(1)) const debouncedLoad = debounce(() => load(1))
// ==================== ====================
/** 把分类树 + 扁平目录数组组装成 t-tree 所需的数据结构 */
function buildTree(cats: any[], folders: any[]): any[] {
// categoryId
const byCat = new Map<string, any[]>()
for (const f of folders || []) {
const cid = String(f.categoryId ?? '')
if (!byCat.has(cid)) byCat.set(cid, [])
byCat.get(cid)!.push(f)
}
// id
const consumed = new Set<string>()
// parentId
function buildFolderTree(list: any[]): any[] {
const map = new Map<string, any>()
list.forEach(f => map.set(String(f.id), { ...f, children: [] }))
const roots: any[] = []
map.forEach(node => {
const pid = String(node.parentId ?? '')
if (pid && pid !== '0' && map.has(pid)) map.get(pid)!.children.push(node)
else roots.push(node)
})
return roots
}
// t-tree
function folderNode(f: any): any {
return {
value: 'folder:' + f.id,
label: f.name,
nodeType: 'folder',
categoryId: f.categoryId,
folderId: f.id,
children: (f.children || []).map(folderNode),
}
}
// t-tree children = +
function categoryNode(c: any): any {
const cid = String(c.id)
consumed.add(cid)
const folderRoots = buildFolderTree(byCat.get(cid) || []).map(folderNode)
const subCats = (c.children || []).map(categoryNode)
return {
value: 'cat:' + cid,
label: c.name,
nodeType: 'category',
categoryId: c.id,
children: [...folderRoots, ...subCats],
}
}
const roots = (cats || []).map(categoryNode)
//
const orphanIds: string[] = []
for (const cid of byCat.keys()) {
if (!consumed.has(cid)) orphanIds.push(cid)
}
if (orphanIds.length) {
const orphanFolders = orphanIds.flatMap(cid => buildFolderTree(byCat.get(cid) || []).map(folderNode))
roots.push({ value: 'cat:0', label: '未分类', nodeType: 'category', categoryId: '0', children: orphanFolders })
}
return roots
}
/** 拉取分类树与目录列表并组装目录树 */
async function loadTreeData() {
folderTreeLoading.value = true
try {
const [catRes, folderRes] = await Promise.all([getCategoryTree(), getFolderList()])
const cats = catRes.success ? (catRes.data || []) : []
const folders = folderRes.success ? (folderRes.data || []) : []
treeData.value = buildTree(cats, folders)
} catch (e: any) {
toast('加载目录树失败:' + e.message, 'error')
} finally {
folderTreeLoading.value = false
}
}
/** 目录树节点激活:按类型联动分类/目录筛选 */
function onTreeActive(value: any[], ctx: any) {
// trigger=setItem
if (!value || value.length === 0) return
if (ctx?.trigger === 'setItem') return
const data = ctx?.node?.data
if (!data) return
if (data.nodeType === 'category') {
filterCategory.value = data.categoryId != null ? String(data.categoryId) : ''
filterFolder.value = ''
} else if (data.nodeType === 'folder') {
filterFolder.value = String(data.folderId)
filterCategory.value = data.categoryId != null ? String(data.categoryId) : ''
}
load(1)
}
/** 分类下拉变化时:清空目录筛选与树高亮 */
function onCategoryChange() {
filterFolder.value = ''
activedKeys.value = []
load(1)
}
/** 清除目录筛选(点击工具栏标签关闭) */
function clearFolderFilter() {
filterFolder.value = ''
activedKeys.value = []
load(1)
}
/** 表格排序变化:更新排序状态并重新加载(服务端排序) */
function onSortChange(sort: any) {
sortInfo.value = sort && sort.sortBy ? { sortBy: sort.sortBy, descending: !!sort.descending } : null
load(1)
}
// ==================== ====================
function openCreateFolder(categoryId: any, parentId?: any) {
folderDialog.value = {
visible: true,
title: parentId != null ? '新建子目录' : '新建目录',
name: '',
mode: 'create',
categoryId: String(categoryId),
parentId: parentId != null ? String(parentId) : '',
folderId: '',
}
}
function openRenameFolder(node: any) {
folderDialog.value = {
visible: true,
title: '重命名目录',
name: node.label || '',
mode: 'rename',
categoryId: '',
parentId: '',
folderId: String(node.folderId),
}
}
async function submitFolderDialog() {
const name = folderDialog.value.name.trim()
if (!name) { toast('请输入目录名称', 'error'); return }
try {
if (folderDialog.value.mode === 'create') {
const payload: any = { name, categoryId: folderDialog.value.categoryId }
if (folderDialog.value.parentId) payload.parentId = folderDialog.value.parentId
const r = await createFolder(payload)
if (r.success) { toast('目录创建成功', 'success'); folderDialog.value.visible = false; await loadTreeData() }
else toast(r.message || '创建失败', 'error')
} else {
const r = await updateFolder(folderDialog.value.folderId, { name })
if (r.success) { toast('目录重命名成功', 'success'); folderDialog.value.visible = false; await loadTreeData() }
else toast(r.message || '重命名失败', 'error')
}
} catch (e: any) { toast('操作失败:' + e.message, 'error') }
}
async function removeFolder(node: any) {
if (!await confirm(`确定删除目录「${node.label}」?目录内文档将被移到分类根`)) return
try {
const r = await deleteFolder(node.folderId)
if (r.success) {
const moved = r.data?.movedDocuments
toast(moved != null ? `目录已删除,${moved} 个文档已移至分类根` : (r.message || '目录已删除'), 'success')
//
if (filterFolder.value === String(node.folderId)) { filterFolder.value = ''; activedKeys.value = [] }
await loadTreeData()
loadData()
documentStore.loadStats()
} else toast(r.message || '删除失败', 'error')
} catch (e: any) { toast('删除失败:' + e.message, 'error') }
}
// ==================== ==================== // ==================== ====================
async function loadData(p = page.value) { async function loadData(p = page.value) {
page.value = p page.value = p
loading.value = true loading.value = true
try { try {
const json = await listDocuments(p, pageSize.value, filterCategory.value || undefined, filterStatus.value || undefined, keyword.value.trim() || undefined, filterTag.value || undefined)
const json = await listDocuments(p, pageSize.value, filterCategory.value || undefined, filterStatus.value || undefined, keyword.value.trim() || undefined, filterTag.value || undefined, filterFolder.value || undefined, sortInfo.value?.sortBy || undefined, sortInfo.value ? (sortInfo.value.descending ? 'desc' : 'asc') : undefined)
if (json.success) { if (json.success) {
documents.value = (json.data || []).map((d: any) => ({ ...d, _enabled: d.enabled !== false })) documents.value = (json.data || []).map((d: any) => ({ ...d, _enabled: d.enabled !== false }))
total.value = json.total || 0 total.value = json.total || 0
@ -256,9 +514,33 @@ async function batchMove() {
} }
// //
onMounted(async () => { await loadTreeData() })
loadData() loadData()
</script> </script>
<style scoped> <style scoped>
.selected-count { font-size: 12px; font-weight: 600; color: var(--td-text-color-primary); } .selected-count { font-size: 12px; font-weight: 600; color: var(--td-text-color-primary); }
/* 左右分栏布局 */
.doc-layout { display: flex; gap: 16px; align-items: flex-start; }
.doc-tree-panel {
width: 240px;
flex-shrink: 0;
min-height: 420px;
padding-right: 12px;
border-right: 1px solid var(--color-border);
}
.doc-content { flex: 1; min-width: 0; }
.tree-panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.tree-panel-title { font-size: 13px; font-weight: 600; }
.tree-loading { padding: 16px; font-size: 13px; color: var(--td-text-color-placeholder); }
/* 树节点 hover 操作按钮 */
.tree-node { display: flex; align-items: center; justify-content: space-between; width: 100%; min-width: 0; }
.tree-label-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-ops { display: none; align-items: center; gap: 0; flex-shrink: 0; }
.tree-node:hover .tree-ops { display: inline-flex; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
</style> </style>

169
frontend/src/views/DocUpload.vue

@ -5,6 +5,16 @@
<!-- 上传元信息 --> <!-- 上传元信息 -->
<div class="upload-meta"> <div class="upload-meta">
<t-select v-model="uploadCategory" :options="categoryOptions" placeholder="选择分类(可选)" clearable size="small" style="width:200px;" /> <t-select v-model="uploadCategory" :options="categoryOptions" placeholder="选择分类(可选)" clearable size="small" style="width:200px;" />
<t-tree-select
v-model="uploadFolderId"
:data="folderTreeData"
:keys="{ value: 'value', label: 'label', children: 'children' }"
clearable
filterable
placeholder="选择目录(可选,默认分类根)"
size="small"
style="width:240px;"
/>
<div class="tag-input-wrapper"> <div class="tag-input-wrapper">
<t-tag v-for="(tag, idx) in tagList" :key="idx" closable size="small" @close="removeTag(idx)">{{ tag }}</t-tag> <t-tag v-for="(tag, idx) in tagList" :key="idx" closable size="small" @close="removeTag(idx)">{{ tag }}</t-tag>
<t-input v-model="tagInputValue" placeholder="输入标签,回车添加" size="small" borderless <t-input v-model="tagInputValue" placeholder="输入标签,回车添加" size="small" borderless
@ -15,6 +25,11 @@
<!-- 上传模式 Tab --> <!-- 上传模式 Tab -->
<t-tabs v-model="uploadMode"> <t-tabs v-model="uploadMode">
<t-tab-panel value="file" label="📄 文件上传"> <t-tab-panel value="file" label="📄 文件上传">
<div class="folder-upload-bar">
<t-button variant="outline" size="small" :loading="folderUploading" @click="pickFolder">📁 选择文件夹上传</t-button>
<span v-if="folderUploading" class="folder-progress">上传中 {{ folderProgress ?? 0 }}%</span>
<input ref="folderInput" type="file" webkitdirectory style="display:none;" @change="onFolderSelected" />
</div>
<t-upload :auto-upload="false" theme="file-flow" multiple :before-upload="beforeFileUpload" <t-upload :auto-upload="false" theme="file-flow" multiple :before-upload="beforeFileUpload"
:request-method="handleFileUpload" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.json,.csv,.html,.xml,.rtf" :request-method="handleFileUpload" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.json,.csv,.html,.xml,.rtf"
:size-limit="{ size: 50, unit: 'MB', message: '文件大小不能超过 50MB' }" /> :size-limit="{ size: 50, unit: 'MB', message: '文件大小不能超过 50MB' }" />
@ -35,11 +50,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import type { UploadFile } from 'tdesign-vue-next' import type { UploadFile } from 'tdesign-vue-next'
import { useCategoryStore } from '@/stores/category' import { useCategoryStore } from '@/stores/category'
import { useDocumentStore } from '@/stores/document' import { useDocumentStore } from '@/stores/document'
import { uploadFile, uploadMarkdown, uploadJsonBasic } from '@/api/upload'
import { uploadFile, uploadMarkdown, uploadJsonBasic, uploadFolder } from '@/api/upload'
import { getFolderList } from '@/api/folder'
import { getCategoryTree } from '@/api/category'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
const categoryStore = useCategoryStore() const categoryStore = useCategoryStore()
@ -49,6 +66,15 @@ const uploadMode = ref('file')
const uploadCategory = ref('') const uploadCategory = ref('')
const uploading = ref(false) const uploading = ref(false)
// value 'folder:xxx' =
const uploadFolderId = ref('')
const folderTreeData = ref<any[]>([])
//
const folderInput = ref<HTMLInputElement | null>(null)
const folderUploading = ref(false)
const folderProgress = ref<number | null>(null)
// //
const tagList = ref<string[]>([]) const tagList = ref<string[]>([])
const tagInputValue = ref('') const tagInputValue = ref('')
@ -73,10 +99,20 @@ function handleTagBackspace() {
if (!tagInputValue.value && tagList.value.length > 0) removeTag(tagList.value.length - 1) if (!tagInputValue.value && tagList.value.length > 0) removeTag(tagList.value.length - 1)
} }
function buildFormData(file: File): FormData {
/** 去掉 'folder:' 前缀,取真实目录 id */
function stripFolderPrefix(value: string): string {
return value.replace(/^folder:/, '')
}
/**
* 组装表单数据
* @param includeFolder 是否附带 folderId目录选择仅对文件/文件夹上传生效文本/Markdown/JSON 暂不生效
*/
function buildFormData(file: File, includeFolder = false): FormData {
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
if (uploadCategory.value) fd.append('categoryId', uploadCategory.value) if (uploadCategory.value) fd.append('categoryId', uploadCategory.value)
if (includeFolder && uploadFolderId.value) fd.append('folderId', stripFolderPrefix(uploadFolderId.value))
if (tagList.value.length) fd.append('tags', tagList.value.join(',')) if (tagList.value.length) fd.append('tags', tagList.value.join(','))
return fd return fd
} }
@ -98,7 +134,7 @@ async function handleFileUpload(file: UploadFile | UploadFile[]) {
try { try {
const raw = pickRawFile(file) const raw = pickRawFile(file)
if (!raw) return { status: 'fail', error: '未获取到文件', response: {} } if (!raw) return { status: 'fail', error: '未获取到文件', response: {} }
const fd = buildFormData(raw)
const fd = buildFormData(raw, true)
await uploadFile(fd) await uploadFile(fd)
toast('上传成功,文档正在处理中', 'success') toast('上传成功,文档正在处理中', 'success')
documentStore.loadStats() documentStore.loadStats()
@ -145,9 +181,134 @@ async function uploadText() {
} catch (e: any) { toast('上传失败:' + e.message, 'error') } } catch (e: any) { toast('上传失败:' + e.message, 'error') }
finally { uploading.value = false } finally { uploading.value = false }
} }
// ==================== t-tree-select ====================
/** 组装「分类 → 目录」可选树:分类节点禁用仅作分组,目录节点可选中 */
function buildFolderSelectTree(cats: any[], folders: any[]): any[] {
const byCat = new Map<string, any[]>()
for (const f of folders || []) {
const cid = String(f.categoryId ?? '')
if (!byCat.has(cid)) byCat.set(cid, [])
byCat.get(cid)!.push(f)
}
function buildFolderTree(list: any[]): any[] {
const map = new Map<string, any>()
list.forEach(f => map.set(String(f.id), { ...f, children: [] }))
const roots: any[] = []
map.forEach(node => {
const pid = String(node.parentId ?? '')
if (pid && pid !== '0' && map.has(pid)) map.get(pid)!.children.push(node)
else roots.push(node)
})
return roots
}
function folderNode(f: any): any {
return { value: 'folder:' + f.id, label: f.name, children: (f.children || []).map(folderNode) }
}
function categoryNode(c: any): any {
return {
value: 'cat:' + c.id,
label: c.name,
disabled: true,
children: [
...buildFolderTree(byCat.get(String(c.id)) || []).map(folderNode),
...(c.children || []).map(categoryNode),
],
}
}
return (cats || []).map(categoryNode)
}
async function loadFolderTree() {
try {
const [catRes, folderRes] = await Promise.all([getCategoryTree(), getFolderList()])
const cats = catRes.success ? (catRes.data || []) : []
const folders = folderRes.success ? (folderRes.data || []) : []
folderTreeData.value = buildFolderSelectTree(cats, folders)
} catch (e: any) {
toast('加载目录失败:' + e.message, 'error')
}
}
// ==================== ====================
function pickFolder() {
folderInput.value?.click()
}
async function onFolderSelected(e: Event) {
const input = e.target as HTMLInputElement
const list = Array.from(input.files || [])
if (list.length === 0) { input.value = ''; return }
// webkitdirectory webkitRelativePath
const supportsDir = 'webkitdirectory' in input
const hasRelativePath = list.some((f: any) => f.webkitRelativePath)
if (!supportsDir || !hasRelativePath) {
toast('当前浏览器不支持文件夹上传,请改用单文件上传', 'warning')
input.value = ''
return
}
//
const valid: File[] = []
const skipped: string[] = []
for (const f of list) {
const ext = f.name.split('.').pop()?.toLowerCase() || ''
if (!ALLOWED_EXT.has(ext)) { skipped.push(f.name + '(类型不支持)'); continue }
if (f.size > 50 * 1024 * 1024) { skipped.push(f.name + '(超过 50MB)'); continue }
valid.push(f)
}
if (skipped.length) toast(`已跳过 ${skipped.length} 个不支持的文件:${skipped.join('、')}`, 'warning')
if (valid.length === 0) { toast('所选文件夹内没有可上传的文件', 'error'); input.value = ''; return }
// FormDatafiles + relativePaths
const fd = new FormData()
valid.forEach(f => fd.append('files', f))
const relativePaths: string[] = valid.map(f => {
const p = (f as any).webkitRelativePath || ''
const idx = p.indexOf('/')
return idx >= 0 ? p.slice(idx + 1) : f.name
})
relativePaths.forEach(p => fd.append('relativePaths', p))
if (uploadCategory.value) fd.append('categoryId', uploadCategory.value)
if (uploadFolderId.value) fd.append('folderId', stripFolderPrefix(uploadFolderId.value))
if (tagList.value.length) fd.append('tags', tagList.value.join(','))
folderUploading.value = true
folderProgress.value = 0
try {
const r = await uploadFolder(fd, (pct) => { folderProgress.value = pct })
if (r.success) {
const successCount = r.successCount ?? r.data?.successCount
const failCount = r.failCount ?? r.data?.failCount ?? 0
if (failCount > 0) {
toast(`文件夹上传完成:成功 ${successCount ?? '?'} 个,失败 ${failCount}`, 'warning')
} else {
toast(`文件夹上传完成:成功 ${successCount ?? valid.length}`, 'success')
}
documentStore.loadStats()
} else {
toast(r.message || '文件夹上传失败', 'error')
}
} catch (e: any) {
toast('上传失败:' + e.message, 'error')
} finally {
folderUploading.value = false
folderProgress.value = null
input.value = ''
}
}
onMounted(async () => { await loadFolderTree() })
</script> </script>
<style scoped> <style scoped>
.upload-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 12px; background: var(--color-bg-subtle); border-radius: 8px; border: 1px solid var(--color-border); margin-bottom: 16px; } .upload-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 12px; background: var(--color-bg-subtle); border-radius: 8px; border: 1px solid var(--color-border); margin-bottom: 16px; }
.tag-input-wrapper { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; flex: 1; padding: 4px 8px; border: 1px solid var(--color-border); border-radius: 4px; background: var(--td-bg-color-container); } .tag-input-wrapper { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; flex: 1; padding: 4px 8px; border: 1px solid var(--color-border); border-radius: 4px; background: var(--td-bg-color-container); }
.folder-upload-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.folder-progress { font-size: 12px; color: var(--td-text-color-secondary); }
</style> </style>

59
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -62,6 +62,13 @@ public class DatabaseInitConfig {
safeInit("迁移 knowledge_document.extra_config 列", this::addDocumentExtraConfigColumn); safeInit("迁移 knowledge_document.extra_config 列", this::addDocumentExtraConfigColumn);
safeInit("迁移 knowledge_document.file_path 列", this::addDocumentFilePathColumn); safeInit("迁移 knowledge_document.file_path 列", this::addDocumentFilePathColumn);
safeInit("创建知识文档目录表 knowledge_folder", () -> {
if (!checkTableExists("knowledge_folder")) {
createKnowledgeFolderTable();
}
});
safeInit("迁移 knowledge_document.folder_id 列", this::addDocumentFolderIdColumn);
safeInit("创建客服角色表 customer_service_role", () -> { safeInit("创建客服角色表 customer_service_role", () -> {
if (!checkTableExists("customer_service_role")) { if (!checkTableExists("customer_service_role")) {
createCustomerServiceRoleTable(); createCustomerServiceRoleTable();
@ -269,6 +276,7 @@ public class DatabaseInitConfig {
private void verifyInitialization() { private void verifyInitialization() {
String[] expectedTables = { String[] expectedTables = {
"chat_message", "knowledge_category", "knowledge_document", "chat_message", "knowledge_category", "knowledge_document",
"knowledge_folder",
"customer_service_role", "customer_service_role_category", "customer_service_role", "customer_service_role_category",
"customer_account", "conversation_session", "ai_model_config", "customer_account", "conversation_session", "ai_model_config",
"sensitive_word", "content_audit_log", "message_feedback", "sensitive_word", "content_audit_log", "message_feedback",
@ -347,6 +355,26 @@ public class DatabaseInitConfig {
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_category_parent ON knowledge_category (parent_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_category_parent ON knowledge_category (parent_id)");
} }
private void createKnowledgeFolderTable() {
String sql = """
CREATE TABLE IF NOT EXISTS knowledge_folder (
id BIGSERIAL PRIMARY KEY,
category_id BIGINT DEFAULT 0 NOT NULL,
parent_id BIGINT DEFAULT 0 NOT NULL,
name VARCHAR(255) NOT NULL,
sort_order INTEGER DEFAULT 0 NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
is_delete BOOLEAN DEFAULT FALSE NOT NULL
)
""";
jdbcTemplate.execute(sql);
// 创建索引
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_folder_category ON knowledge_folder (category_id)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_folder_parent ON knowledge_folder (parent_id)");
}
private void createDocumentTable() { private void createDocumentTable() {
String sql = """ String sql = """
CREATE TABLE IF NOT EXISTS knowledge_document ( CREATE TABLE IF NOT EXISTS knowledge_document (
@ -685,6 +713,25 @@ public class DatabaseInitConfig {
} }
} }
/**
* 自动添加 folder_id 文档目录功能新增字段
* 幂等已有列则跳过
*/
private void addDocumentFolderIdColumn() {
try {
String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'knowledge_document' AND column_name = 'folder_id'";
Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class);
if (count != null && count == 0) {
log.info("添加 knowledge_document.folder_id 列");
jdbcTemplate.execute("ALTER TABLE knowledge_document ADD COLUMN folder_id BIGINT DEFAULT 0 NOT NULL");
}
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_folder ON knowledge_document (folder_id)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_dedup ON knowledge_document (category_id, folder_id, content_hash)");
} catch (Exception e) {
log.error("添加 knowledge_document.folder_id 列失败,请手动执行: ALTER TABLE knowledge_document ADD COLUMN folder_id BIGINT DEFAULT 0 NOT NULL", e);
}
}
// ==================== P0-004: 内容安全过滤 ==================== // ==================== P0-004: 内容安全过滤 ====================
private void createSensitiveWordTable() { private void createSensitiveWordTable() {
@ -1460,6 +1507,7 @@ public class DatabaseInitConfig {
executeComment("COLUMN knowledge_document.file_size", "文件大小(字节)"); executeComment("COLUMN knowledge_document.file_size", "文件大小(字节)");
executeComment("COLUMN knowledge_document.content", "原文内容(截断预览)"); executeComment("COLUMN knowledge_document.content", "原文内容(截断预览)");
executeComment("COLUMN knowledge_document.category_id", "所属分类 ID(0 表示未分类)"); executeComment("COLUMN knowledge_document.category_id", "所属分类 ID(0 表示未分类)");
executeComment("COLUMN knowledge_document.folder_id", "所属目录 ID(0 表示未指定目录,直接挂分类根)");
executeComment("COLUMN knowledge_document.tags", "标签(JSON 格式)"); executeComment("COLUMN knowledge_document.tags", "标签(JSON 格式)");
executeComment("COLUMN knowledge_document.chunk_count", "分块数量"); executeComment("COLUMN knowledge_document.chunk_count", "分块数量");
executeComment("COLUMN knowledge_document.status", "处理状态: PROCESSING / READY / FAILED"); executeComment("COLUMN knowledge_document.status", "处理状态: PROCESSING / READY / FAILED");
@ -1472,6 +1520,17 @@ public class DatabaseInitConfig {
executeComment("COLUMN knowledge_document.update_time", "更新时间"); executeComment("COLUMN knowledge_document.update_time", "更新时间");
executeComment("COLUMN knowledge_document.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); executeComment("COLUMN knowledge_document.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
// ===== knowledge_folder =====
executeComment("TABLE knowledge_folder", "知识库文档目录表(支持分类下的目录树形结构)");
executeComment("COLUMN knowledge_folder.id", "主键(雪花算法生成)");
executeComment("COLUMN knowledge_folder.category_id", "所属分类 ID(关联 knowledge_category.id)");
executeComment("COLUMN knowledge_folder.parent_id", "父目录 ID(0 表示该分类下的根目录)");
executeComment("COLUMN knowledge_folder.name", "目录名称");
executeComment("COLUMN knowledge_folder.sort_order", "排序权重(数值越大越靠前)");
executeComment("COLUMN knowledge_folder.create_time", "创建时间");
executeComment("COLUMN knowledge_folder.update_time", "更新时间");
executeComment("COLUMN knowledge_folder.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
// ===== customer_service_role ===== // ===== customer_service_role =====
executeComment("TABLE customer_service_role", "客服角色表(定义客服角色的身份与系统提示词)"); executeComment("TABLE customer_service_role", "客服角色表(定义客服角色的身份与系统提示词)");
executeComment("COLUMN customer_service_role.id", "主键"); executeComment("COLUMN customer_service_role.id", "主键");

59
src/main/java/com/wok/supportbot/controller/DocumentController.java

@ -74,6 +74,7 @@ public class DocumentController {
* @param file 文件 * @param file 文件
* @param title 文档标题可选默认使用文件名 * @param title 文档标题可选默认使用文件名
* @param categoryId 分类ID可选 * @param categoryId 分类ID可选
* @param folderId 目录ID可选
* @param tags 标签可选 * @param tags 标签可选
* @return 上传结果 * @return 上传结果
*/ */
@ -83,12 +84,13 @@ public class DocumentController {
@RequestParam("file") MultipartFile file, @RequestParam("file") MultipartFile file,
@RequestParam(required = false) String title, @RequestParam(required = false) String title,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) Long folderId,
@RequestParam(required = false) List<String> tags, @RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer chunkSize,
@RequestParam(required = false) Integer overlap) { @RequestParam(required = false) Integer overlap) {
try { try {
validateUploadFile(file); validateUploadFile(file);
KnowledgeDocument doc = documentService.uploadFile(file, title, categoryId, tags, chunkSize, overlap);
KnowledgeDocument doc = documentService.uploadFile(file, title, categoryId, folderId, tags, chunkSize, overlap);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "文件上传成功,正在后台处理", "message", "文件上传成功,正在后台处理",
@ -117,11 +119,12 @@ public class DocumentController {
@RequestBody String content, @RequestBody String content,
@RequestParam String title, @RequestParam String title,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) Long folderId,
@RequestParam(required = false) List<String> tags, @RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer chunkSize,
@RequestParam(required = false) Integer overlap) { @RequestParam(required = false) Integer overlap) {
try { try {
KnowledgeDocument doc = documentService.uploadString(content, title, categoryId, tags, chunkSize, overlap);
KnowledgeDocument doc = documentService.uploadString(content, title, categoryId, folderId, tags, chunkSize, overlap);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "文本内容上传成功,正在后台处理", "message", "文本内容上传成功,正在后台处理",
@ -144,12 +147,13 @@ public class DocumentController {
@RequestParam("file") MultipartFile file, @RequestParam("file") MultipartFile file,
@RequestParam(required = false) String title, @RequestParam(required = false) String title,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) Long folderId,
@RequestParam(required = false) List<String> tags, @RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer chunkSize,
@RequestParam(required = false) Integer overlap) { @RequestParam(required = false) Integer overlap) {
try { try {
validateUploadFile(file); validateUploadFile(file);
KnowledgeDocument doc = documentService.uploadMarkdown(file, title, categoryId, tags, chunkSize, overlap);
KnowledgeDocument doc = documentService.uploadMarkdown(file, title, categoryId, folderId, tags, chunkSize, overlap);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "Markdown文件上传成功,正在后台处理", "message", "Markdown文件上传成功,正在后台处理",
@ -172,12 +176,13 @@ public class DocumentController {
@RequestParam("file") MultipartFile file, @RequestParam("file") MultipartFile file,
@RequestParam(required = false) String title, @RequestParam(required = false) String title,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) Long folderId,
@RequestParam(required = false) List<String> tags, @RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer chunkSize,
@RequestParam(required = false) Integer overlap) { @RequestParam(required = false) Integer overlap) {
try { try {
validateUploadFile(file); validateUploadFile(file);
KnowledgeDocument doc = documentService.uploadJsonBasic(file, title, categoryId, tags, chunkSize, overlap);
KnowledgeDocument doc = documentService.uploadJsonBasic(file, title, categoryId, folderId, tags, chunkSize, overlap);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "JSON文件上传成功,正在后台处理", "message", "JSON文件上传成功,正在后台处理",
@ -201,12 +206,13 @@ public class DocumentController {
@RequestParam("fields") List<String> fields, @RequestParam("fields") List<String> fields,
@RequestParam(required = false) String title, @RequestParam(required = false) String title,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) Long folderId,
@RequestParam(required = false) List<String> tags, @RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer chunkSize,
@RequestParam(required = false) Integer overlap) { @RequestParam(required = false) Integer overlap) {
try { try {
validateUploadFile(file); validateUploadFile(file);
KnowledgeDocument doc = documentService.uploadJsonFields(file, fields, title, categoryId, tags, chunkSize, overlap);
KnowledgeDocument doc = documentService.uploadJsonFields(file, fields, title, categoryId, folderId, tags, chunkSize, overlap);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "JSON文件(按字段)上传成功,正在后台处理", "message", "JSON文件(按字段)上传成功,正在后台处理",
@ -231,12 +237,13 @@ public class DocumentController {
@RequestParam("pointer") String pointer, @RequestParam("pointer") String pointer,
@RequestParam(required = false) String title, @RequestParam(required = false) String title,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) Long folderId,
@RequestParam(required = false) List<String> tags, @RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer chunkSize,
@RequestParam(required = false) Integer overlap) { @RequestParam(required = false) Integer overlap) {
try { try {
validateUploadFile(file); validateUploadFile(file);
KnowledgeDocument doc = documentService.uploadJsonPointer(file, pointer, title, categoryId, tags, chunkSize, overlap);
KnowledgeDocument doc = documentService.uploadJsonPointer(file, pointer, title, categoryId, folderId, tags, chunkSize, overlap);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "JSON文件(按指针)上传成功,正在后台处理", "message", "JSON文件(按指针)上传成功,正在后台处理",
@ -251,6 +258,38 @@ public class DocumentController {
} }
} }
/**
* 文件夹批量上传按相对路径自动创建子目录
*/
@PostMapping("/upload/folder")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> uploadFolder(
@RequestParam("files") MultipartFile[] files,
@RequestParam("relativePaths") String[] relativePaths,
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) Long folderId,
@RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Integer chunkSize,
@RequestParam(required = false) Integer overlap) {
try {
Map<String, Object> result = documentService.uploadFolder(
Arrays.asList(files), Arrays.asList(relativePaths),
categoryId, folderId, tags, chunkSize, overlap);
int successCount = (int) result.get("successCount");
int failCount = (int) result.get("failCount");
return ResponseEntity.ok(Map.of(
"success", true,
"message", String.format("文件夹上传完成:成功 %d 个,失败 %d 个", successCount, failCount),
"data", result
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "文件夹上传失败:" + e.getMessage()
));
}
}
// ==================== 文档管理 ==================== // ==================== 文档管理 ====================
/** /**
@ -295,6 +334,7 @@ public class DocumentController {
* @param page 页码默认1 * @param page 页码默认1
* @param size 每页大小默认10 * @param size 每页大小默认10
* @param categoryId 分类ID过滤可选 * @param categoryId 分类ID过滤可选
* @param folderId 目录ID过滤可选0 表示只看根目录
* @param status 状态过滤PROCESSING/READY/FAILED可选 * @param status 状态过滤PROCESSING/READY/FAILED可选
* @param keyword 关键词搜索模糊匹配标题和文件名可选 * @param keyword 关键词搜索模糊匹配标题和文件名可选
* @param tag 标签筛选精确匹配可选 * @param tag 标签筛选精确匹配可选
@ -306,11 +346,14 @@ public class DocumentController {
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) Long folderId,
@RequestParam(required = false) String status, @RequestParam(required = false) String status,
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@RequestParam(required = false) String tag) {
@RequestParam(required = false) String tag,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try { try {
Map<String, Object> result = documentService.listDocuments(page, size, categoryId, status, keyword, tag);
Map<String, Object> result = documentService.listDocuments(page, size, categoryId, folderId, status, keyword, tag, sortField, sortOrder);
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("success", true); data.put("success", true);
data.put("data", result.get("records")); data.put("data", result.get("records"));

136
src/main/java/com/wok/supportbot/controller/FolderController.java

@ -0,0 +1,136 @@
package com.wok.supportbot.controller;
import com.wok.supportbot.entity.FolderNode;
import com.wok.supportbot.entity.KnowledgeFolder;
import com.wok.supportbot.service.FolderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 知识库文档目录控制器
* 提供目录的树形查询扁平列表创建重命名删除等功能
*/
@RestController
public class FolderController {
@Autowired
private FolderService folderService;
/**
* 获取目录树
*/
@GetMapping("/folder/tree")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> getFolderTree(@RequestParam(required = false) Long categoryId) {
try {
List<FolderNode> tree = folderService.getFolderTree(categoryId);
return ResponseEntity.ok(Map.of(
"success", true,
"data", tree
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "获取目录树失败:" + e.getMessage()
));
}
}
/**
* 获取目录扁平列表
*/
@GetMapping("/folder/list")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> listFolders(@RequestParam(required = false) Long categoryId) {
try {
List<KnowledgeFolder> list = folderService.listFolders(categoryId);
return ResponseEntity.ok(Map.of(
"success", true,
"data", list
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "获取目录列表失败:" + e.getMessage()
));
}
}
/**
* 创建目录
*/
@PostMapping("/folder")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> createFolder(@RequestBody Map<String, Object> body) {
try {
String name = (String) body.get("name");
Long categoryId = body.get("categoryId") != null ? Long.valueOf(body.get("categoryId").toString()) : null;
Long parentId = body.get("parentId") != null ? Long.valueOf(body.get("parentId").toString()) : null;
Integer sortOrder = body.get("sortOrder") != null ? Integer.valueOf(body.get("sortOrder").toString()) : null;
KnowledgeFolder folder = folderService.createFolder(name, categoryId, parentId, sortOrder);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "目录创建成功",
"data", folder
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "创建目录失败:" + e.getMessage()
));
}
}
/**
* 重命名目录 / 调整排序
*/
@PutMapping("/folder/{id}")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> renameFolder(
@PathVariable Long id,
@RequestBody Map<String, Object> body) {
try {
String name = (String) body.get("name");
Integer sortOrder = body.get("sortOrder") != null ? Integer.valueOf(body.get("sortOrder").toString()) : null;
KnowledgeFolder folder = folderService.renameFolder(id, name, sortOrder);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "目录更新成功",
"data", folder
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "更新目录失败:" + e.getMessage()
));
}
}
/**
* 删除目录级联删除子孙目录并将其下文档移到分类根
*/
@DeleteMapping("/folder/{id}")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> deleteFolder(@PathVariable Long id) {
try {
Map<String, Object> result = folderService.deleteFolder(id);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "目录删除成功",
"data", result
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "删除目录失败:" + e.getMessage()
));
}
}
}

12
src/main/java/com/wok/supportbot/dao/KnowledgeFolderMapper.java

@ -0,0 +1,12 @@
package com.wok.supportbot.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wok.supportbot.entity.KnowledgeFolder;
import org.apache.ibatis.annotations.Mapper;
/**
* 知识库文档目录 Mapper
*/
@Mapper
public interface KnowledgeFolderMapper extends BaseMapper<KnowledgeFolder> {
}

63
src/main/java/com/wok/supportbot/entity/FolderNode.java

@ -0,0 +1,63 @@
package com.wok.supportbot.entity;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
/**
* 目录树节点 - 用于返回树形结构
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class FolderNode implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 目录ID
*/
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
/**
* 目录名称
*/
private String name;
/**
* 所属分类ID
*/
@JsonSerialize(using = ToStringSerializer.class)
private Long categoryId;
/**
* 父目录ID
*/
@JsonSerialize(using = ToStringSerializer.class)
private Long parentId;
/**
* 排序权重
*/
private Integer sortOrder;
/**
* 目录内文档数量
*/
private Integer documentCount;
/**
* 子目录列表
*/
private List<FolderNode> children;
}

7
src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java

@ -75,6 +75,13 @@ public class KnowledgeDocument implements Serializable {
@JsonSerialize(using = ToStringSerializer.class) @JsonSerialize(using = ToStringSerializer.class)
private Long categoryId; private Long categoryId;
/**
* 所属目录ID - 0表示未指定目录(直接挂分类根)
*/
@TableField("folder_id")
@JsonSerialize(using = ToStringSerializer.class)
private Long folderId;
/** /**
* 标签列表(JSON数组) * 标签列表(JSON数组)
*/ */

80
src/main/java/com/wok/supportbot/entity/KnowledgeFolder.java

@ -0,0 +1,80 @@
package com.wok.supportbot.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* 知识库文档目录表 - 支持分类下的目录树形结构
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName("knowledge_folder")
public class KnowledgeFolder implements Serializable {
@Serial
@TableField(exist = false)
private static final long serialVersionUID = 1L;
/**
* 目录ID
*/
@TableId(value = "id", type = IdType.ASSIGN_ID)
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
/**
* 所属分类ID
*/
@TableField("category_id")
@JsonSerialize(using = ToStringSerializer.class)
private Long categoryId;
/**
* 父目录ID0=该分类下的根目录
*/
@TableField("parent_id")
@JsonSerialize(using = ToStringSerializer.class)
private Long parentId;
/**
* 目录名称
*/
@TableField("name")
private String name;
/**
* 排序权重(越大越靠前)
*/
@TableField("sort_order")
private Integer sortOrder;
/**
* 创建时间
*/
@TableField(value = "create_time", fill = FieldFill.INSERT)
private Date createTime;
/**
* 更新时间
*/
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
/**
* 逻辑删除标志 - false:未删除, true:已删除(逻辑删除)
*/
@TableField("is_delete")
@TableLogic
private boolean isDelete;
}

205
src/main/java/com/wok/supportbot/service/DocumentService.java

@ -12,6 +12,7 @@ import com.wok.supportbot.document.transform.MyTokenTextSplitter;
import com.wok.supportbot.entity.CategoryNode; import com.wok.supportbot.entity.CategoryNode;
import com.wok.supportbot.entity.KnowledgeCategory; import com.wok.supportbot.entity.KnowledgeCategory;
import com.wok.supportbot.entity.KnowledgeDocument; import com.wok.supportbot.entity.KnowledgeDocument;
import com.wok.supportbot.entity.KnowledgeFolder;
import com.wok.supportbot.entity.SearchResult; import com.wok.supportbot.entity.SearchResult;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document; import org.springframework.ai.document.Document;
@ -73,6 +74,9 @@ public class DocumentService {
@Autowired @Autowired
private DocumentProcessingService documentProcessingService; private DocumentProcessingService documentProcessingService;
@Autowired
private FolderService folderService;
@Autowired @Autowired
private com.wok.supportbot.config.FileStorageConfig fileStorageConfig; private com.wok.supportbot.config.FileStorageConfig fileStorageConfig;
@ -97,6 +101,7 @@ public class DocumentService {
* @param chunkSize 分块大小可选覆盖全局配置 * @param chunkSize 分块大小可选覆盖全局配置
* @param overlap 重叠大小可选覆盖全局配置 * @param overlap 重叠大小可选覆盖全局配置
* @param filePath 原始文件存储路径可选null表示无原始文件 * @param filePath 原始文件存储路径可选null表示无原始文件
* @param folderId 目录ID可选null/0表示不指定目录
* @return 创建完成的文档记录status=PROCESSING * @return 创建完成的文档记录status=PROCESSING
*/ */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@ -104,13 +109,22 @@ public class DocumentService {
String fileType, Long fileSize, String content, String fileType, Long fileSize, String content,
Long categoryId, List<String> tags, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap, Integer chunkSize, Integer overlap,
String filePath) {
// 0. 内容去重检查
String filePath, Long folderId) {
// 0. 若指定目录校验目录存在并强制使用目录所属分类
if (folderId != null && folderId > 0) {
KnowledgeFolder folder = folderService.getFolderById(folderId);
if (folder == null) {
throw new RuntimeException("目录不存在");
}
categoryId = folder.getCategoryId();
}
// 1. 内容去重检查
String contentHash = computeContentHash(content); String contentHash = computeContentHash(content);
if (contentHash != null) { if (contentHash != null) {
String duplicateTitle = checkContentDuplicate(contentHash, categoryId);
String duplicateTitle = checkContentDuplicate(contentHash, categoryId, folderId);
if (duplicateTitle != null) { if (duplicateTitle != null) {
throw new RuntimeException("文档内容重复,该分类下已有相同内容的文档: " + duplicateTitle);
throw new RuntimeException("文档内容重复,该目录下已有相同内容的文档: " + duplicateTitle);
} }
} }
@ -119,7 +133,7 @@ public class DocumentService {
if (chunkSize != null) extraConfig.put("chunkSize", chunkSize); if (chunkSize != null) extraConfig.put("chunkSize", chunkSize);
if (overlap != null) extraConfig.put("overlap", overlap); if (overlap != null) extraConfig.put("overlap", overlap);
// 1. 创建文档记录状态 PROCESSING
// 2. 创建文档记录状态 PROCESSING
KnowledgeDocument docRecord = KnowledgeDocument.builder() KnowledgeDocument docRecord = KnowledgeDocument.builder()
.title(title != null ? title : sourceName) .title(title != null ? title : sourceName)
.sourceName(sourceName) .sourceName(sourceName)
@ -128,6 +142,7 @@ public class DocumentService {
.filePath(filePath) .filePath(filePath)
.content(content != null && content.length() > 2000 ? content.substring(0, 2000) : content) .content(content != null && content.length() > 2000 ? content.substring(0, 2000) : content)
.categoryId(categoryId != null ? categoryId : 0L) .categoryId(categoryId != null ? categoryId : 0L)
.folderId(folderId != null ? folderId : 0L)
.tags(tags != null ? Map.of("tags", tags) : null) .tags(tags != null ? Map.of("tags", tags) : null)
.contentHash(contentHash) .contentHash(contentHash)
.enabled(true) .enabled(true)
@ -137,7 +152,7 @@ public class DocumentService {
.build(); .build();
documentMapper.insert(docRecord); documentMapper.insert(docRecord);
// 2. 触发异步处理分块 关键词 向量化 更新状态
// 3. 触发异步处理分块 关键词 向量化 更新状态
documentProcessingService.processDocumentAsync( documentProcessingService.processDocumentAsync(
docRecord.getId(), documents, sourceName, title, categoryId, tags, docRecord.getId(), documents, sourceName, title, categoryId, tags,
chunkSize, overlap); chunkSize, overlap);
@ -149,8 +164,8 @@ public class DocumentService {
/** /**
* 解析文件并上传同时保存原始文件到本地 * 解析文件并上传同时保存原始文件到本地
*/ */
public KnowledgeDocument uploadFile(MultipartFile file, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
public KnowledgeDocument uploadFile(MultipartFile file, String title, Long categoryId, Long folderId,
List<String> tags, Integer chunkSize, Integer overlap) {
// 1. 先保存原始文件到本地磁盘必须在解析之前因为 transferTo 只能调用一次 // 1. 先保存原始文件到本地磁盘必须在解析之前因为 transferTo 只能调用一次
String relativePath = saveFileToLocal(file); String relativePath = saveFileToLocal(file);
java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile(); java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile();
@ -168,7 +183,7 @@ public class DocumentService {
documents.get(0).getText(), documents.get(0).getText(),
categoryId, categoryId,
tags, chunkSize, overlap, tags, chunkSize, overlap,
relativePath);
relativePath, folderId);
} }
/** /**
@ -201,18 +216,18 @@ public class DocumentService {
/** /**
* 解析字符串并上传无原始文件 * 解析字符串并上传无原始文件
*/ */
public KnowledgeDocument uploadString(String content, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
public KnowledgeDocument uploadString(String content, String title, Long categoryId, Long folderId,
List<String> tags, Integer chunkSize, Integer overlap) {
List<Document> documents = simpleStringDocumentReader.read(content); List<Document> documents = simpleStringDocumentReader.read(content);
return uploadDocument(documents, title, title, "txt", return uploadDocument(documents, title, title, "txt",
(long) content.length(), content, categoryId, tags, chunkSize, overlap, null);
(long) content.length(), content, categoryId, tags, chunkSize, overlap, null, folderId);
} }
/** /**
* 解析 Markdown 文件并上传 * 解析 Markdown 文件并上传
*/ */
public KnowledgeDocument uploadMarkdown(MultipartFile file, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
public KnowledgeDocument uploadMarkdown(MultipartFile file, String title, Long categoryId, Long folderId,
List<String> tags, Integer chunkSize, Integer overlap) {
// 1. 先保存原始文件 // 1. 先保存原始文件
String relativePath = saveFileToLocal(file); String relativePath = saveFileToLocal(file);
java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile(); java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile();
@ -229,14 +244,14 @@ public class DocumentService {
content, content,
categoryId, categoryId,
tags, chunkSize, overlap, tags, chunkSize, overlap,
relativePath);
relativePath, folderId);
} }
/** /**
* 解析 JSON 文件基本方式并上传 * 解析 JSON 文件基本方式并上传
*/ */
public KnowledgeDocument uploadJsonBasic(MultipartFile file, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
public KnowledgeDocument uploadJsonBasic(MultipartFile file, String title, Long categoryId, Long folderId,
List<String> tags, Integer chunkSize, Integer overlap) {
// 1. 先保存原始文件 // 1. 先保存原始文件
String relativePath = saveFileToLocal(file); String relativePath = saveFileToLocal(file);
java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile(); java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile();
@ -253,14 +268,14 @@ public class DocumentService {
content, content,
categoryId, categoryId,
tags, chunkSize, overlap, tags, chunkSize, overlap,
relativePath);
relativePath, folderId);
} }
/** /**
* 解析 JSON 文件按字段并上传 * 解析 JSON 文件按字段并上传
*/ */
public KnowledgeDocument uploadJsonFields(MultipartFile file, List<String> fields, String title, public KnowledgeDocument uploadJsonFields(MultipartFile file, List<String> fields, String title,
Long categoryId, List<String> tags,
Long categoryId, Long folderId, List<String> tags,
Integer chunkSize, Integer overlap) { Integer chunkSize, Integer overlap) {
// 1. 先保存原始文件 // 1. 先保存原始文件
String relativePath = saveFileToLocal(file); String relativePath = saveFileToLocal(file);
@ -278,14 +293,14 @@ public class DocumentService {
content, content,
categoryId, categoryId,
tags, chunkSize, overlap, tags, chunkSize, overlap,
relativePath);
relativePath, folderId);
} }
/** /**
* 解析 JSON 文件按指针并上传 * 解析 JSON 文件按指针并上传
*/ */
public KnowledgeDocument uploadJsonPointer(MultipartFile file, String pointer, String title, public KnowledgeDocument uploadJsonPointer(MultipartFile file, String pointer, String title,
Long categoryId, List<String> tags,
Long categoryId, Long folderId, List<String> tags,
Integer chunkSize, Integer overlap) { Integer chunkSize, Integer overlap) {
// 1. 先保存原始文件 // 1. 先保存原始文件
String relativePath = saveFileToLocal(file); String relativePath = saveFileToLocal(file);
@ -303,7 +318,117 @@ public class DocumentService {
content, content,
categoryId, categoryId,
tags, chunkSize, overlap, tags, chunkSize, overlap,
relativePath);
relativePath, folderId);
}
/**
* 文件夹批量上传按相对路径解析子目录结构逐文件上传
*
* @param files 文件列表
* @param relativePaths 每个文件对应的相对路径 "dir1/dir2/file.txt"
* @param categoryId 分类ID可选baseFolderId 0 时作为目标分类
* @param folderId 基础目录ID可选null/0 表示从分类根目录开始
* @param tags 标签列表可选
* @param chunkSize 分块大小可选
* @param overlap 重叠大小可选
* @return 批量上传结果successCount/failCount/details
*/
public Map<String, Object> uploadFolder(List<MultipartFile> files, List<String> relativePaths,
Long categoryId, Long folderId, List<String> tags,
Integer chunkSize, Integer overlap) {
if (files == null || files.isEmpty() || relativePaths == null || relativePaths.isEmpty()) {
throw new RuntimeException("文件和路径列表不能为空");
}
if (files.size() != relativePaths.size()) {
throw new RuntimeException("文件与路径数量不一致");
}
if (files.size() > 500) {
throw new RuntimeException("单次文件夹上传最多支持 500 个文件");
}
// 解析目标分类基础目录存在时以其所属分类为准否则使用入参分类
Long baseFolderId = folderId != null ? folderId : 0L;
Long targetCategoryId;
if (baseFolderId > 0) {
KnowledgeFolder base = folderService.getFolderById(baseFolderId);
if (base == null) {
throw new RuntimeException("目录不存在");
}
targetCategoryId = base.getCategoryId();
} else {
targetCategoryId = categoryId;
}
int successCount = 0;
int failCount = 0;
List<Map<String, Object>> details = new ArrayList<>();
for (int i = 0; i < files.size(); i++) {
String originalPath = relativePaths.get(i);
try {
String sanitized = sanitizeRelativePath(originalPath);
List<String> segs = new ArrayList<>(List.of(sanitized.split("/")));
// 目录段 = 去掉最后一段文件名
List<String> dirSegs = segs.subList(0, segs.size() - 1);
Long targetFolderId;
if (!dirSegs.isEmpty()) {
if (targetCategoryId == null || targetCategoryId == 0) {
throw new RuntimeException("上传含子目录时必须先选择分类");
}
targetFolderId = folderService.ensureFolderPath(targetCategoryId, baseFolderId, dirSegs);
} else {
targetFolderId = baseFolderId;
}
uploadFile(files.get(i), null, targetCategoryId, targetFolderId, tags, chunkSize, overlap);
successCount++;
} catch (Exception e) {
failCount++;
details.add(Map.of("file", originalPath, "error", e.getMessage()));
log.warn("文件夹上传单个文件失败: file={}", originalPath, e);
}
}
Map<String, Object> result = new HashMap<>();
result.put("successCount", successCount);
result.put("failCount", failCount);
result.put("details", details);
return result;
}
/**
* 净化相对路径反斜杠转正斜杠去开头斜杠校验非法段
*
* @param path 原始相对路径
* @return 净化后的路径
*/
private String sanitizeRelativePath(String path) {
if (path == null || path.isEmpty()) {
throw new RuntimeException("文件相对路径不能为空");
}
String normalized = path.replace('\\', '/');
while (normalized.startsWith("/")) {
normalized = normalized.substring(1);
}
if (normalized.isEmpty()) {
throw new RuntimeException("文件相对路径不能为空");
}
String[] parts = normalized.split("/");
for (String part : parts) {
if (part.isEmpty()) {
throw new RuntimeException("文件相对路径包含空目录段: " + path);
}
if (".".equals(part) || "..".equals(part)) {
throw new RuntimeException("文件相对路径包含非法目录段: " + path);
}
if (part.length() > 255) {
throw new RuntimeException("目录名超过 255 字符限制: " + part);
}
}
if (parts.length > 32) {
throw new RuntimeException("目录层级超过 32 层限制");
}
return normalized;
} }
// ==================== 文件下载 ==================== // ==================== 文件下载 ====================
@ -334,9 +459,10 @@ public class DocumentService {
// ==================== 文档管理 ==================== // ==================== 文档管理 ====================
/** /**
* 分页查询文档列表手动分页支持关键词搜索 + 标签筛选
* 分页查询文档列表手动分页支持关键词搜索 + 标签筛选 + 目录筛选
*/ */
public Map<String, Object> listDocuments(int page, int size, Long categoryId, String status, String keyword, String tag) {
public Map<String, Object> listDocuments(int page, int size, Long categoryId, Long folderId, String status,
String keyword, String tag, String sortField, String sortOrder) {
// 参数安全校验 // 参数安全校验
if (page < 1) page = 1; if (page < 1) page = 1;
if (size < 1 || size > 100) size = 10; if (size < 1 || size > 100) size = 10;
@ -349,6 +475,9 @@ public class DocumentService {
if (categoryId != null && categoryId > 0) { if (categoryId != null && categoryId > 0) {
countWrapper.eq("category_id", categoryId); countWrapper.eq("category_id", categoryId);
} }
if (folderId != null) {
countWrapper.eq("folder_id", folderId);
}
if (status != null && !status.isEmpty()) { if (status != null && !status.isEmpty()) {
countWrapper.eq("status", status); countWrapper.eq("status", status);
} }
@ -374,6 +503,9 @@ public class DocumentService {
if (categoryId != null && categoryId > 0) { if (categoryId != null && categoryId > 0) {
listWrapper.eq("category_id", categoryId); listWrapper.eq("category_id", categoryId);
} }
if (folderId != null) {
listWrapper.eq("folder_id", folderId);
}
if (status != null && !status.isEmpty()) { if (status != null && !status.isEmpty()) {
listWrapper.eq("status", status); listWrapper.eq("status", status);
} }
@ -389,7 +521,20 @@ public class DocumentService {
log.warn("构建标签筛选 JSON 失败: tag={}", tag, e); log.warn("构建标签筛选 JSON 失败: tag={}", tag, e);
} }
} }
listWrapper.orderByDesc("create_time");
// 排序字段白名单前端 colKey -> 数据库列名 SQL 注入
Map<String, String> sortColumns = Map.of(
"title", "title",
"fileType", "file_type",
"fileSize", "file_size",
"chunkCount", "chunk_count",
"createTime", "create_time");
// sortField 可能为 null需先判空避免不可变 Map getOrDefault(null) 触发 NPE
String sortColumn = sortField != null ? sortColumns.getOrDefault(sortField, "create_time") : "create_time";
if ("asc".equalsIgnoreCase(sortOrder)) {
listWrapper.orderByAsc(sortColumn);
} else {
listWrapper.orderByDesc(sortColumn);
}
listWrapper.last("LIMIT " + size + " OFFSET " + (page - 1) * size); listWrapper.last("LIMIT " + size + " OFFSET " + (page - 1) * size);
List<KnowledgeDocument> records = documentMapper.selectList(listWrapper); List<KnowledgeDocument> records = documentMapper.selectList(listWrapper);
@ -1035,10 +1180,13 @@ public class DocumentService {
} }
/** /**
* 删除分类不删除文档仅清空关联
* 删除分类不删除文档仅清空关联级联清理该分类下的目录
*/ */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void deleteCategory(Long id) { public void deleteCategory(Long id) {
// 级联清理该分类下的目录及其子孙目录文档 folder_id 0目录逻辑删除
folderService.deleteFoldersByCategoryId(id);
// 将关联的文档 category_id 设为 0 // 将关联的文档 category_id 设为 0
KnowledgeDocument updateDoc = new KnowledgeDocument(); KnowledgeDocument updateDoc = new KnowledgeDocument();
updateDoc.setCategoryId(0L); updateDoc.setCategoryId(0L);
@ -1074,15 +1222,18 @@ public class DocumentService {
/** /**
* 检查内容是否重复 * 检查内容是否重复
* @param contentHash 内容哈希值 * @param contentHash 内容哈希值
* @param categoryId 分类ID
* @param folderId 目录IDnull/0 表示分类根目录
* @return 重复文档的标题如果不存在重复则返回 null * @return 重复文档的标题如果不存在重复则返回 null
*/ */
private String checkContentDuplicate(String contentHash, Long categoryId) {
private String checkContentDuplicate(String contentHash, Long categoryId, Long folderId) {
if (contentHash == null) { if (contentHash == null) {
return null; return null;
} }
QueryWrapper<KnowledgeDocument> wrapper = new QueryWrapper<>(); QueryWrapper<KnowledgeDocument> wrapper = new QueryWrapper<>();
wrapper.eq("content_hash", contentHash); wrapper.eq("content_hash", contentHash);
wrapper.eq("category_id", categoryId != null ? categoryId : 0L); wrapper.eq("category_id", categoryId != null ? categoryId : 0L);
wrapper.eq("folder_id", folderId != null ? folderId : 0L);
wrapper.select("title"); wrapper.select("title");
List<KnowledgeDocument> existing = documentMapper.selectList(wrapper); List<KnowledgeDocument> existing = documentMapper.selectList(wrapper);
return existing != null && !existing.isEmpty() ? existing.get(0).getTitle() : null; return existing != null && !existing.isEmpty() ? existing.get(0).getTitle() : null;

313
src/main/java/com/wok/supportbot/service/FolderService.java

@ -0,0 +1,313 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.dao.KnowledgeCategoryMapper;
import com.wok.supportbot.dao.KnowledgeDocumentMapper;
import com.wok.supportbot.dao.KnowledgeFolderMapper;
import com.wok.supportbot.entity.FolderNode;
import com.wok.supportbot.entity.KnowledgeCategory;
import com.wok.supportbot.entity.KnowledgeDocument;
import com.wok.supportbot.entity.KnowledgeFolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 知识库文档目录服务
* 管理分类下的目录树形结构支持目录的增删改查与级联处理
*/
@Service
@Slf4j
public class FolderService {
@Autowired
private KnowledgeFolderMapper folderMapper;
@Autowired
private KnowledgeCategoryMapper categoryMapper;
@Autowired
private KnowledgeDocumentMapper documentMapper;
/**
* 获取目录树 parentId 拼装parentId null/0 的作为根节点
*
* @param categoryId 分类ID可选为空时查询所有目录
* @return 目录树根节点列表
*/
public List<FolderNode> getFolderTree(Long categoryId) {
QueryWrapper<KnowledgeFolder> wrapper = new QueryWrapper<>();
if (categoryId != null) {
wrapper.eq("category_id", categoryId);
}
wrapper.orderByAsc("sort_order");
List<KnowledgeFolder> folders = folderMapper.selectList(wrapper);
Map<Long, FolderNode> nodeMap = new LinkedHashMap<>();
List<FolderNode> rootNodes = new ArrayList<>();
for (KnowledgeFolder folder : folders) {
FolderNode node = FolderNode.builder()
.id(folder.getId())
.name(folder.getName())
.categoryId(folder.getCategoryId())
.parentId(folder.getParentId())
.sortOrder(folder.getSortOrder())
.documentCount(0)
.children(new ArrayList<>())
.build();
nodeMap.put(folder.getId(), node);
}
for (FolderNode node : nodeMap.values()) {
if (node.getParentId() == null || node.getParentId() == 0) {
rootNodes.add(node);
} else {
FolderNode parent = nodeMap.get(node.getParentId());
if (parent != null) {
parent.getChildren().add(node);
} else {
// 父目录缺失时降级为根节点避免节点丢失
rootNodes.add(node);
}
}
}
return rootNodes;
}
/**
* 获取目录扁平列表
*
* @param categoryId 分类ID可选
* @return 目录列表
*/
public List<KnowledgeFolder> listFolders(Long categoryId) {
QueryWrapper<KnowledgeFolder> wrapper = new QueryWrapper<>();
if (categoryId != null) {
wrapper.eq("category_id", categoryId);
}
wrapper.orderByAsc("sort_order");
return folderMapper.selectList(wrapper);
}
/**
* 根据ID查询目录逻辑删除的目录返回 null
*
* @param id 目录ID
* @return 目录实体
*/
public KnowledgeFolder getFolderById(Long id) {
if (id == null || id <= 0) {
return null;
}
return folderMapper.selectById(id);
}
/**
* 创建目录
*
* @param name 目录名称
* @param categoryId 所属分类ID必须存在且未删除
* @param parentId 父目录ID缺省/0 表示分类根目录
* @param sortOrder 排序权重
* @return 创建完成的目录实体
*/
public KnowledgeFolder createFolder(String name, Long categoryId, Long parentId, Integer sortOrder) {
if (name == null || name.trim().isEmpty()) {
throw new RuntimeException("目录名称不能为空");
}
name = name.trim();
if (categoryId == null || categoryId <= 0) {
throw new RuntimeException("所属分类不存在");
}
KnowledgeCategory category = categoryMapper.selectById(categoryId);
if (category == null || category.isDelete()) {
throw new RuntimeException("所属分类不存在");
}
Long parent = (parentId == null || parentId == 0) ? 0L : parentId;
if (parent > 0) {
KnowledgeFolder parentFolder = folderMapper.selectById(parent);
if (parentFolder == null || parentFolder.isDelete()) {
throw new RuntimeException("父目录不存在");
}
if (parentFolder.getCategoryId() == null || !parentFolder.getCategoryId().equals(categoryId)) {
throw new RuntimeException("父目录不属于该分类");
}
}
// 同父同名查重
QueryWrapper<KnowledgeFolder> wrapper = new QueryWrapper<>();
wrapper.eq("category_id", categoryId);
wrapper.eq("parent_id", parent);
wrapper.eq("name", name);
if (folderMapper.selectCount(wrapper) > 0) {
throw new RuntimeException("同级目录下已存在同名目录");
}
KnowledgeFolder folder = KnowledgeFolder.builder()
.name(name)
.categoryId(categoryId)
.parentId(parent)
.sortOrder(sortOrder != null ? sortOrder : 0)
.build();
folderMapper.insert(folder);
log.info("创建目录: id={}, name={}, categoryId={}, parentId={}", folder.getId(), name, categoryId, parent);
return folder;
}
/**
* 重命名目录 / 调整排序
*
* @param id 目录ID
* @param name 新名称可选
* @param sortOrder 新排序权重可选
* @return 更新后的目录实体
*/
public KnowledgeFolder renameFolder(Long id, String name, Integer sortOrder) {
KnowledgeFolder folder = folderMapper.selectById(id);
if (folder == null) {
throw new RuntimeException("目录不存在");
}
if (name != null && !name.trim().isEmpty()) {
String newName = name.trim();
// 同父同名查重排除自身
QueryWrapper<KnowledgeFolder> wrapper = new QueryWrapper<>();
wrapper.eq("category_id", folder.getCategoryId());
wrapper.eq("parent_id", folder.getParentId());
wrapper.eq("name", newName);
wrapper.ne("id", id);
if (folderMapper.selectCount(wrapper) > 0) {
throw new RuntimeException("同级目录下已存在同名目录");
}
folder.setName(newName);
}
if (sortOrder != null) {
folder.setSortOrder(sortOrder);
}
folderMapper.updateById(folder);
return folder;
}
/**
* 删除目录级联逻辑删除自身与所有子孙目录并将其下文档 folder_id 0
*
* @param id 目录ID
* @return 删除结果删除的目录数 + 移动的文档数
*/
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> deleteFolder(Long id) {
KnowledgeFolder folder = folderMapper.selectById(id);
if (folder == null) {
throw new RuntimeException("目录不存在");
}
List<Long> ids = collectDescendantIds(id);
// 将该目录及其子孙目录下的文档 folder_id 0不改变 categoryId
KnowledgeDocument updateDoc = new KnowledgeDocument();
updateDoc.setFolderId(0L);
int movedCount = documentMapper.update(updateDoc,
new QueryWrapper<KnowledgeDocument>().in("folder_id", ids));
// 逻辑删除目录含子孙
folderMapper.deleteBatchIds(ids);
log.info("删除目录及子孙: id={}, 删除目录数={}, 移动文档数={}", id, ids.size(), movedCount);
return Map.of("deletedFolders", ids.size(), "movedDocuments", movedCount);
}
/**
* 清理指定分类下的所有目录及其子孙目录用于删除分类时的级联处理
*
* @param categoryId 分类ID
* @return 删除的目录数量
*/
@Transactional(rollbackFor = Exception.class)
public int deleteFoldersByCategoryId(Long categoryId) {
QueryWrapper<KnowledgeFolder> wrapper = new QueryWrapper<>();
wrapper.eq("category_id", categoryId);
List<KnowledgeFolder> folders = folderMapper.selectList(wrapper);
List<Long> ids = new ArrayList<>();
for (KnowledgeFolder folder : folders) {
ids.addAll(collectDescendantIds(folder.getId()));
}
// 去重多个根目录的子孙理论上不重叠防御性处理
ids = ids.stream().distinct().collect(Collectors.toList());
if (ids.isEmpty()) {
return 0;
}
KnowledgeDocument updateDoc = new KnowledgeDocument();
updateDoc.setFolderId(0L);
documentMapper.update(updateDoc, new QueryWrapper<KnowledgeDocument>().in("folder_id", ids));
folderMapper.deleteBatchIds(ids);
log.info("清理分类目录: categoryId={}, 删除目录数={}", categoryId, ids.size());
return ids.size();
}
/**
* 从基础目录出发逐段确保目录路径存在并返回最终目录ID
*
* @param categoryId 目标分类ID
* @param baseFolderId 基础目录IDnull/0 表示分类根目录
* @param segments 目录段列表不包含文件名
* @return 最终目录ID
*/
public Long ensureFolderPath(Long categoryId, Long baseFolderId, List<String> segments) {
Long currentParentId = (baseFolderId == null || baseFolderId == 0) ? 0L : baseFolderId;
for (String segment : segments) {
currentParentId = getOrCreateFolder(categoryId, currentParentId, segment);
}
return currentParentId;
}
/**
* 在指定父目录下查找目录不存在则创建返回目录ID
*
* @param categoryId 分类ID
* @param parentId 父目录ID
* @param name 目录名称
* @return 目录ID
*/
private Long getOrCreateFolder(Long categoryId, Long parentId, String name) {
QueryWrapper<KnowledgeFolder> wrapper = new QueryWrapper<>();
wrapper.eq("category_id", categoryId);
wrapper.eq("parent_id", parentId != null ? parentId : 0L);
wrapper.eq("name", name);
List<KnowledgeFolder> existing = folderMapper.selectList(wrapper);
if (existing != null && !existing.isEmpty()) {
return existing.get(0).getId();
}
return createFolder(name, categoryId, parentId, null).getId();
}
/**
* 递归收集目录自身及所有子孙目录的ID
*
* @param rootId 根目录ID
* @return 目录ID列表含根目录
*/
private List<Long> collectDescendantIds(Long rootId) {
List<Long> all = new ArrayList<>();
all.add(rootId);
List<Long> current = new ArrayList<>();
current.add(rootId);
while (!current.isEmpty()) {
QueryWrapper<KnowledgeFolder> wrapper = new QueryWrapper<>();
wrapper.in("parent_id", current);
List<KnowledgeFolder> children = folderMapper.selectList(wrapper);
List<Long> next = new ArrayList<>();
for (KnowledgeFolder child : children) {
all.add(child.getId());
next.add(child.getId());
}
current = next;
}
return all;
}
}

36
src/main/resources/init-database.sql

@ -1,7 +1,7 @@
-- ============================================================ -- ============================================================
-- AI 智能客服系统 - 数据库初始化脚本(完整版) -- AI 智能客服系统 - 数据库初始化脚本(完整版)
-- 适用环境: PostgreSQL 12+ 且已安装 pgvector 扩展 -- 适用环境: PostgreSQL 12+ 且已安装 pgvector 扩展
-- 表数量: 27 张(含 vector_store;其余与 DatabaseInitConfig 的 26 张 expectedTables 对齐)
-- 表数量: 28 张(含 vector_store;其余与 DatabaseInitConfig 的 27 张 expectedTables 对齐)
-- 注: vector_store 由 Spring AI 自动建表,此处手动建表作为备份方案 -- 注: vector_store 由 Spring AI 自动建表,此处手动建表作为备份方案
-- --
-- ⚠️ 开发规范:任何涉及建表、增删改列、初始数据的变更, -- ⚠️ 开发规范:任何涉及建表、增删改列、初始数据的变更,
@ -87,6 +87,33 @@ COMMENT ON COLUMN knowledge_category.document_count IS '关联文档数(冗余
COMMENT ON COLUMN knowledge_category.create_time IS '创建时间'; COMMENT ON COLUMN knowledge_category.create_time IS '创建时间';
COMMENT ON COLUMN knowledge_category.is_delete IS '逻辑删除'; COMMENT ON COLUMN knowledge_category.is_delete IS '逻辑删除';
-- ============================================================
-- 表 2.5: knowledge_folder — 知识库文档目录表
-- ============================================================
CREATE TABLE IF NOT EXISTS knowledge_folder (
id BIGSERIAL PRIMARY KEY,
category_id BIGINT NOT NULL DEFAULT 0,
parent_id BIGINT NOT NULL DEFAULT 0,
name VARCHAR(255) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_delete BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_knowledge_folder_category ON knowledge_folder (category_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_folder_parent ON knowledge_folder (parent_id);
COMMENT ON TABLE knowledge_folder IS '知识库文档目录表(支持分类下的目录树形结构)';
COMMENT ON COLUMN knowledge_folder.id IS '主键';
COMMENT ON COLUMN knowledge_folder.category_id IS '所属分类ID(关联 knowledge_category.id)';
COMMENT ON COLUMN knowledge_folder.parent_id IS '父目录ID(0 表示该分类下的根目录)';
COMMENT ON COLUMN knowledge_folder.name IS '目录名称';
COMMENT ON COLUMN knowledge_folder.sort_order IS '排序权重(越大越靠前)';
COMMENT ON COLUMN knowledge_folder.create_time IS '创建时间';
COMMENT ON COLUMN knowledge_folder.update_time IS '更新时间';
COMMENT ON COLUMN knowledge_folder.is_delete IS '逻辑删除';
-- ============================================================ -- ============================================================
-- 表 3: knowledge_document — 知识文档表 -- 表 3: knowledge_document — 知识文档表
-- ============================================================ -- ============================================================
@ -99,6 +126,7 @@ CREATE TABLE IF NOT EXISTS knowledge_document (
file_path VARCHAR(500), file_path VARCHAR(500),
content TEXT, content TEXT,
category_id BIGINT NOT NULL DEFAULT 0, category_id BIGINT NOT NULL DEFAULT 0,
folder_id BIGINT NOT NULL DEFAULT 0,
tags JSONB NOT NULL DEFAULT '{}', tags JSONB NOT NULL DEFAULT '{}',
chunk_count INTEGER NOT NULL DEFAULT 0, chunk_count INTEGER NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING', status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING',
@ -112,6 +140,8 @@ CREATE TABLE IF NOT EXISTS knowledge_document (
); );
CREATE INDEX IF NOT EXISTS idx_knowledge_document_category ON knowledge_document (category_id); CREATE INDEX IF NOT EXISTS idx_knowledge_document_category ON knowledge_document (category_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_document_folder ON knowledge_document (folder_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_document_dedup ON knowledge_document (category_id, folder_id, content_hash);
CREATE INDEX IF NOT EXISTS idx_knowledge_document_status ON knowledge_document (status); CREATE INDEX IF NOT EXISTS idx_knowledge_document_status ON knowledge_document (status);
CREATE INDEX IF NOT EXISTS idx_knowledge_document_create_time ON knowledge_document (create_time DESC); CREATE INDEX IF NOT EXISTS idx_knowledge_document_create_time ON knowledge_document (create_time DESC);
CREATE INDEX IF NOT EXISTS idx_knowledge_document_content_hash ON knowledge_document (content_hash); CREATE INDEX IF NOT EXISTS idx_knowledge_document_content_hash ON knowledge_document (content_hash);
@ -126,6 +156,7 @@ COMMENT ON COLUMN knowledge_document.file_size IS '文件大小(字节)';
COMMENT ON COLUMN knowledge_document.file_path IS '原始文件存储路径(相对路径)'; COMMENT ON COLUMN knowledge_document.file_path IS '原始文件存储路径(相对路径)';
COMMENT ON COLUMN knowledge_document.content IS '原文内容(截断预览)'; COMMENT ON COLUMN knowledge_document.content IS '原文内容(截断预览)';
COMMENT ON COLUMN knowledge_document.category_id IS '所属分类ID'; COMMENT ON COLUMN knowledge_document.category_id IS '所属分类ID';
COMMENT ON COLUMN knowledge_document.folder_id IS '所属目录ID(0 表示未指定目录,直接挂分类根)';
COMMENT ON COLUMN knowledge_document.tags IS '标签(JSON)'; COMMENT ON COLUMN knowledge_document.tags IS '标签(JSON)';
COMMENT ON COLUMN knowledge_document.chunk_count IS '分块数量'; COMMENT ON COLUMN knowledge_document.chunk_count IS '分块数量';
COMMENT ON COLUMN knowledge_document.status IS '状态: PROCESSING / READY / FAILED'; COMMENT ON COLUMN knowledge_document.status IS '状态: PROCESSING / READY / FAILED';
@ -137,6 +168,9 @@ COMMENT ON COLUMN knowledge_document.create_time IS '创建时间';
COMMENT ON COLUMN knowledge_document.update_time IS '更新时间'; COMMENT ON COLUMN knowledge_document.update_time IS '更新时间';
COMMENT ON COLUMN knowledge_document.is_delete IS '逻辑删除'; COMMENT ON COLUMN knowledge_document.is_delete IS '逻辑删除';
-- 老库升级:补充 folder_id 列(新建库已在 CREATE TABLE 中包含,此句幂等)
ALTER TABLE knowledge_document ADD COLUMN IF NOT EXISTS folder_id BIGINT DEFAULT 0 NOT NULL;
-- ============================================================ -- ============================================================
-- 表 4: customer_service_role — 客服角色表 -- 表 4: customer_service_role — 客服角色表
-- ============================================================ -- ============================================================

Loading…
Cancel
Save