14 Commits
8256e9ee85
...
06edac2738
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
06edac2738 |
feat(frontend): 文档列表位置列显示目录路径并修复一屏滚动布局
- 文档列表分类列改为「位置」列:沿 folderMap 回溯 parentId 拼出目录路径(不含分类名),直接挂分类根显示 —,长路径省略号截断+悬停 title 看全 - 修复文档卡片一屏高度布局:补 .t-loading__parent 参与 flex 高度链,目录树/表格在面板内滚动不再撑高整页 |
2 months ago |
|
|
06fbbaa41d |
chore(frontend): 更新 SDK 测试面板构建产物引用
|
2 months ago |
|
|
d0cd06f9fc |
fix(document): 修复批量上传向量化只成功小文档问题并加固处理链路
- 移除逐 chunk 串行 AI 关键词提取(MyKeywordEnricher):产出 excerpt_keywords 全库无检索消费点,却将外部调用放大 chunkCount 倍且与在线对话共用模型抢限流 - 向量化改按批入库(默认 50 块/批, knowledge.vector.batch-size)逐批 try-catch 隔离:失败批只记录缺失区间继续,已入库块保留,error_message 聚合"已入库 x/y 块+缺失区间+原因",chunk_count 改为实际入库块数 - 新增文档级失败自动整体重试(≤2 次, 仅 timeout/429/5xx 等瞬时错误):重试前清残留向量防重复;处理期间删除文档即终止写入避免孤儿向量;外层兜底保证状态不悬挂 PROCESSING - Embedding 客户端(DashScope/OpenAI 兼容/豆包多模态)补显式 connect 10s/read 60s 超时,避免慢响应无限挂起占死 documentExecutor 线程 - 文档同步 CLAUDE.md/README.md,新增 knowledge.vector.batch-size 配置说明 |
2 months ago |
|
|
459cabb1bc |
fix(mcp-server): 修复启用开关触发 toggle/refresh 请求死循环
- 前端 toggleMcpServer 补发 { active } 请求体,与后端 @RequestBody 契约一致(此前 active 拼在 query、无 body,每次点开关必 400)
- 后端 toggleActive 兼容 query 兜底,避免旧调用方仍 400
- axios 拦截器加重试上限(_retry),刷新改用裸 axios 发起,切断 401→refresh→重试 自激递归
- MCP 开关失败回滚 _active 状态并加 _toggling 防连点
|
2 months ago |
|
|
9896dec82a |
feat(frontend): DocList 目录树支持拖拽宽度与更多操作菜单,接入 BaseTable
目录树宽度可拖拽调节(分隔条)并将节点操作收敛为 t-dropdown 更多菜单(新建子目录/重命名/删除);文档表格接入 BaseTable,改为固定表头 + 内容超高内部滚动(基于视口 calc 高度)。因与目录树样式在同一段 CSS 中交织,同文件一并提交 |
2 months ago |
|
|
97f9befc6d |
refactor(frontend): 统一列表表格为 BaseTable 组件
新增 BaseTable 封装 t-table,统一下发小尺寸/悬浮/占满宽度/固定表头,操作列自动右固定,替换 13 个列表页 t-table,补齐缺失 loading;同步 UI 开发准则与组件声明 |
2 months ago |
|
|
1ea991e5b8 |
fix(frontend): 全局重置 body margin 消除页面多余滚动
浏览器默认 body margin:8px 未清零,导致 .layout-container(100vh) 与登录页 min-height:100vh 超出视口出现滚动条及四周白边。在 shared.css 顶部新增全局基础重置(margin/padding 清零 + box-sizing:border-box)。 |
2 months ago |
|
|
2668b8e73b |
feat(document): 文档列表支持移动到目录(统一分类根/目录)
- 后端 moveDocuments 统一移动:目标为目录时校验存在并强制采用目录所属分类,分类变更时同步 vector_store 的 categoryId metadata - DocumentController 批量移动接口新增 folderId 参数(extractLong 兼容雪花 ID 字符串) - 前端 DocList 新增单行/批量「移动」弹窗,复用目录树 t-tree 选目标,替换原「移动分类」 |
2 months ago |
|
|
f06422d5e9 |
docs: 补充开发规范与踩坑记录
- 列表排序字段白名单、vector_store 物理删除、数据库初始化幂等自愈、Java 文本块拼 SQL 等规则 |
2 months ago |
|
|
1ffd960377 |
feat(document): 目录树独立滚动与文件夹上传完善
- 文档列表目录树独立滚动 - 上传页目录选择器改为先选分类再选目录 - 文件夹上传标题剥离目录前缀(原样文件名) - 部分文件失败时弹窗按原因分组展示失败明细 |
2 months ago |
|
|
562370ece5 |
fix(backend): 数据库初始化幂等自愈与 vector_store 物理删除
- DatabaseInitConfig 改为全幂等自愈(ADD COLUMN IF NOT EXISTS + 回填 + 索引/触发器幂等) - HybridSearchService 移除对 vector_store 的 is_delete 过滤(该表无此列,物理删除) |
2 months ago |
|
|
dc8aa7a9d1 |
refactor(backend): 列表排序字段白名单统一走 SortUtils 防 SQL 注入
- 新增 SortUtils.resolveColumn 统一排序字段白名单映射,未知字段回退默认列 - 各 Controller/Service 列表接口新增 sortField/sortOrder 参数并接入白名单 - 前端列表页列加 sorter 并透传服务端排序参数 |
2 months ago |
|
|
367ec79401 |
fix(document): 修复文件夹上传连接池耗尽与目录树独立滚动
- 移除 DocumentProcessingService 异步方法上的跨方法事务,AI 关键词提取/向量化期间不再占用数据库连接,避免文件夹批量上传时连接池被占满超时 - 新增有界 documentExecutor 线程池,替代默认无界 SimpleAsyncTaskExecutor,防线程爆炸与大模型限流 - 文档列表页目录树与列表改为左右独立滚动 |
2 months ago |
|
|
780917561b |
feat(document): 新增知识库文档目录(文件夹)功能
- 新增 KnowledgeFolder 实体/Mapper、FolderService、FolderController,支持目录树增删改查 - 文档新增 folderId 字段,列表支持按目录筛选与服务端排序 - 新增文件夹批量上传(按相对路径自动创建子目录) - 文档列表页新增左侧目录树面板,上传页新增目录选择与文件夹上传 |
2 months ago |
75 changed files with 2805 additions and 510 deletions
-
53CLAUDE.md
-
9README.md
-
10frontend/UI-DEV-GUIDE.md
-
4frontend/components.d.ts
-
7frontend/src/api/api-key.ts
-
4frontend/src/api/conversation.ts
-
11frontend/src/api/document.ts
-
4frontend/src/api/faq.ts
-
17frontend/src/api/folder.ts
-
4frontend/src/api/llm-trace.ts
-
9frontend/src/api/mcp-server.ts
-
4frontend/src/api/model-config.ts
-
16frontend/src/api/request.ts
-
8frontend/src/api/sensitive-word.ts
-
5frontend/src/api/upload.ts
-
4frontend/src/api/user.ts
-
7frontend/src/api/webhook.ts
-
40frontend/src/components/BaseTable.vue
-
7frontend/src/styles/shared.css
-
15frontend/src/types/models.ts
-
11frontend/src/views/AccountManager.vue
-
12frontend/src/views/ApiKeyManager.vue
-
12frontend/src/views/AuditLogManager.vue
-
23frontend/src/views/CategoryManager.vue
-
16frontend/src/views/ConversationManager.vue
-
6frontend/src/views/DashboardPanel.vue
-
495frontend/src/views/DocList.vue
-
192frontend/src/views/DocUpload.vue
-
18frontend/src/views/FaqManager.vue
-
18frontend/src/views/FeedbackOps.vue
-
25frontend/src/views/McpServerManager.vue
-
20frontend/src/views/ModelConfigManager.vue
-
52frontend/src/views/PromptTracePanel.vue
-
20frontend/src/views/SensitiveWordManager.vue
-
19frontend/src/views/UserManager.vue
-
12frontend/src/views/WebhookManager.vue
-
40src/main/java/com/wok/supportbot/common/SortUtils.java
-
18src/main/java/com/wok/supportbot/config/AsyncExecutorConfig.java
-
78src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
-
30src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java
-
16src/main/java/com/wok/supportbot/config/VolcengineMultimodalEmbeddingModel.java
-
6src/main/java/com/wok/supportbot/controller/AiModelConfigController.java
-
6src/main/java/com/wok/supportbot/controller/ApiKeyController.java
-
6src/main/java/com/wok/supportbot/controller/ConversationController.java
-
87src/main/java/com/wok/supportbot/controller/DocumentController.java
-
6src/main/java/com/wok/supportbot/controller/FaqController.java
-
136src/main/java/com/wok/supportbot/controller/FolderController.java
-
6src/main/java/com/wok/supportbot/controller/LlmCallTraceController.java
-
17src/main/java/com/wok/supportbot/controller/McpServerConfigController.java
-
6src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java
-
22src/main/java/com/wok/supportbot/controller/SensitiveWordController.java
-
6src/main/java/com/wok/supportbot/controller/SysUserController.java
-
6src/main/java/com/wok/supportbot/controller/WebhookController.java
-
12src/main/java/com/wok/supportbot/dao/KnowledgeFolderMapper.java
-
30src/main/java/com/wok/supportbot/document/transform/MyKeywordEnricher.java
-
63src/main/java/com/wok/supportbot/entity/FolderNode.java
-
7src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java
-
80src/main/java/com/wok/supportbot/entity/KnowledgeFolder.java
-
4src/main/java/com/wok/supportbot/rag/HybridSearchService.java
-
29src/main/java/com/wok/supportbot/service/AiModelConfigService.java
-
17src/main/java/com/wok/supportbot/service/ApiKeyService.java
-
24src/main/java/com/wok/supportbot/service/ConversationService.java
-
437src/main/java/com/wok/supportbot/service/DocumentProcessingService.java
-
290src/main/java/com/wok/supportbot/service/DocumentService.java
-
21src/main/java/com/wok/supportbot/service/FaqService.java
-
313src/main/java/com/wok/supportbot/service/FolderService.java
-
24src/main/java/com/wok/supportbot/service/LlmCallTraceService.java
-
21src/main/java/com/wok/supportbot/service/McpServerConfigService.java
-
22src/main/java/com/wok/supportbot/service/MessageFeedbackService.java
-
19src/main/java/com/wok/supportbot/service/SensitiveWordService.java
-
19src/main/java/com/wok/supportbot/service/SysUserService.java
-
17src/main/java/com/wok/supportbot/service/WebhookService.java
-
3src/main/resources/application.yml
-
42src/main/resources/init-database.sql
-
12src/main/resources/static/sdk/test.html
@ -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) } |
||||
@ -1,12 +1,17 @@ |
|||||
import request from './request' |
import request from './request' |
||||
import type { ApiResponse } from '@/types/api' |
import type { ApiResponse } from '@/types/api' |
||||
|
|
||||
export function listMcpServers(page = 1, size = 10): Promise<ApiResponse> { return request.get(`/mcp-server/list?page=${page}&size=${size}`).then(r => r.data) } |
|
||||
|
export function listMcpServers(page = 1, size = 10, sortField?: string, sortOrder?: string): Promise<ApiResponse> { |
||||
|
let path = `/mcp-server/list?page=${page}&size=${size}` |
||||
|
if (sortField) path += `&sortField=${sortField}` |
||||
|
if (sortOrder) path += `&sortOrder=${sortOrder}` |
||||
|
return request.get(path).then(r => r.data) |
||||
|
} |
||||
export function getMcpServerDetail(id: string): Promise<ApiResponse> { return request.get(`/mcp-server/${id}`).then(r => r.data) } |
export function getMcpServerDetail(id: string): Promise<ApiResponse> { return request.get(`/mcp-server/${id}`).then(r => r.data) } |
||||
export function createMcpServer(data: any): Promise<ApiResponse> { return request.post('/mcp-server', data).then(r => r.data) } |
export function createMcpServer(data: any): Promise<ApiResponse> { return request.post('/mcp-server', data).then(r => r.data) } |
||||
export function updateMcpServer(id: string, data: any): Promise<ApiResponse> { return request.put(`/mcp-server/${id}`, data).then(r => r.data) } |
export function updateMcpServer(id: string, data: any): Promise<ApiResponse> { return request.put(`/mcp-server/${id}`, data).then(r => r.data) } |
||||
export function deleteMcpServer(id: string): Promise<ApiResponse> { return request.delete(`/mcp-server/${id}`).then(r => r.data) } |
export function deleteMcpServer(id: string): Promise<ApiResponse> { return request.delete(`/mcp-server/${id}`).then(r => r.data) } |
||||
export function toggleMcpServer(id: string, active: boolean): Promise<ApiResponse> { return request.put(`/mcp-server/${id}/toggle?active=${active}`).then(r => r.data) } |
|
||||
|
export function toggleMcpServer(id: string, active: boolean): Promise<ApiResponse> { return request.put(`/mcp-server/${id}/toggle`, { active }).then(r => r.data) } |
||||
export function testMcpServer(id: string): Promise<ApiResponse> { return request.post(`/mcp-server/${id}/test`, {}).then(r => r.data) } |
export function testMcpServer(id: string): Promise<ApiResponse> { return request.post(`/mcp-server/${id}/test`, {}).then(r => r.data) } |
||||
export function refreshMcpServers(): Promise<ApiResponse> { return request.post('/mcp-server/refresh', {}).then(r => r.data) } |
export function refreshMcpServers(): Promise<ApiResponse> { return request.post('/mcp-server/refresh', {}).then(r => r.data) } |
||||
export function listAvailableMcpTools(): Promise<ApiResponse> { return request.get('/mcp-server/tools').then(r => r.data) } |
export function listAvailableMcpTools(): Promise<ApiResponse> { return request.get('/mcp-server/tools').then(r => r.data) } |
||||
@ -1,7 +1,12 @@ |
|||||
import request from './request' |
import request from './request' |
||||
import type { ApiResponse } from '@/types/api' |
import type { ApiResponse } from '@/types/api' |
||||
|
|
||||
export function listWebhooks(page = 1, size = 20): Promise<ApiResponse> { return request.get(`/webhook/list?page=${page}&size=${size}`).then(r => r.data) } |
|
||||
|
export function listWebhooks(page = 1, size = 20, sortField?: string, sortOrder?: string): Promise<ApiResponse> { |
||||
|
let path = `/webhook/list?page=${page}&size=${size}` |
||||
|
if (sortField) path += `&sortField=${sortField}` |
||||
|
if (sortOrder) path += `&sortOrder=${sortOrder}` |
||||
|
return request.get(path).then(r => r.data) |
||||
|
} |
||||
export function createWebhook(data: any): Promise<ApiResponse> { return request.post('/webhook', data).then(r => r.data) } |
export function createWebhook(data: any): Promise<ApiResponse> { return request.post('/webhook', data).then(r => r.data) } |
||||
export function updateWebhook(id: string, data: any): Promise<ApiResponse> { return request.put(`/webhook/${id}`, data).then(r => r.data) } |
export function updateWebhook(id: string, data: any): Promise<ApiResponse> { return request.put(`/webhook/${id}`, data).then(r => r.data) } |
||||
export function deleteWebhook(id: string): Promise<ApiResponse> { return request.delete(`/webhook/${id}`).then(r => r.data) } |
export function deleteWebhook(id: string): Promise<ApiResponse> { return request.delete(`/webhook/${id}`).then(r => r.data) } |
||||
|
|||||
@ -0,0 +1,40 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import { computed } from 'vue' |
||||
|
|
||||
|
defineOptions({ inheritAttrs: false }) |
||||
|
|
||||
|
const props = withDefaults( |
||||
|
defineProps<{ |
||||
|
columns?: any[] |
||||
|
/** 统一最大高度,传 '' 或 0 关闭固定表头 */ |
||||
|
maxHeight?: string | number |
||||
|
}>(), |
||||
|
{ |
||||
|
columns: () => [], |
||||
|
maxHeight: 'calc(100vh - 240px)', |
||||
|
}, |
||||
|
) |
||||
|
|
||||
|
// 操作列自动右固定:colKey === 'op' 且未显式 fixed 时补 fixed:'right' |
||||
|
const resolvedColumns = computed(() => |
||||
|
props.columns.map((col) => |
||||
|
col && col.colKey === 'op' && !col.fixed ? { ...col, fixed: 'right' } : col, |
||||
|
), |
||||
|
) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<t-table |
||||
|
v-bind="$attrs" |
||||
|
size="small" |
||||
|
hover |
||||
|
table-layout="fixed" |
||||
|
:max-height="maxHeight" |
||||
|
:columns="resolvedColumns" |
||||
|
> |
||||
|
<!-- 动态透传所有具名插槽(op / title / fileType / ... 及作用域插槽) --> |
||||
|
<template v-for="(_, name) in $slots" #[name]="slotProps"> |
||||
|
<slot :name="name" v-bind="slotProps || {}" /> |
||||
|
</template> |
||||
|
</t-table> |
||||
|
</template> |
||||
@ -0,0 +1,40 @@ |
|||||
|
package com.wok.supportbot.common; |
||||
|
|
||||
|
import java.util.Map; |
||||
|
|
||||
|
/** |
||||
|
* 列表排序工具类。 |
||||
|
* <p> |
||||
|
* 用于统一处理后台列表接口的排序参数:白名单映射(防 SQL 注入)、sortField 判空回退、sortOrder 二值化。 |
||||
|
* 避免在每个 Service 里重复书写「白名单 + getOrDefault(null) 判空 + asc/desc」逻辑。 |
||||
|
*/ |
||||
|
public final class SortUtils { |
||||
|
|
||||
|
private SortUtils() { |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 解析排序字段对应的数据库列名。 |
||||
|
* <p> |
||||
|
* sortField 为 null 或不在白名单内时,回退到 defaultColumn,杜绝将用户输入直接拼入 ORDER BY。 |
||||
|
* |
||||
|
* @param sortField 前端传入的 colKey(可为 null) |
||||
|
* @param whitelist 前端 colKey → 数据库列名(可含表别名)的白名单映射 |
||||
|
* @param defaultColumn 回退列名(通常为 create_time) |
||||
|
* @return 安全列名 |
||||
|
*/ |
||||
|
public static String resolveColumn(String sortField, Map<String, String> whitelist, String defaultColumn) { |
||||
|
if (sortField == null) { |
||||
|
return defaultColumn; |
||||
|
} |
||||
|
String column = whitelist.get(sortField); |
||||
|
return column != null ? column : defaultColumn; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 排序方向二值化:仅 asc 视为升序,其余(含 null、desc、其他值)一律降序。 |
||||
|
*/ |
||||
|
public static boolean isAscending(String sortOrder) { |
||||
|
return "asc".equalsIgnoreCase(sortOrder); |
||||
|
} |
||||
|
} |
||||
@ -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() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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> { |
||||
|
} |
||||
@ -1,30 +0,0 @@ |
|||||
package com.wok.supportbot.document.transform; |
|
||||
|
|
||||
import com.wok.supportbot.config.ChatModelFactory; |
|
||||
import jakarta.annotation.Resource; |
|
||||
import org.springframework.ai.document.Document; |
|
||||
import org.springframework.ai.model.transformer.KeywordMetadataEnricher; |
|
||||
import org.springframework.stereotype.Component; |
|
||||
|
|
||||
import java.util.List; |
|
||||
|
|
||||
/** |
|
||||
* 基于 AI 的文档元信息增强器(为文档补充元信息) |
|
||||
* 通过 ChatModelFactory 获取 ChatModel,支持多提供商动态切换 |
|
||||
*/ |
|
||||
@Component |
|
||||
public class MyKeywordEnricher { |
|
||||
|
|
||||
@Resource |
|
||||
private ChatModelFactory chatModelFactory; |
|
||||
|
|
||||
/** |
|
||||
* 使用 AI 提取关键词并添加到元数据 |
|
||||
*/ |
|
||||
public List<Document> enrichDocuments(List<Document> documents) { |
|
||||
KeywordMetadataEnricher enricher = new KeywordMetadataEnricher.Builder(chatModelFactory.getChatModel("CHAT")) |
|
||||
.keywordCount(5) |
|
||||
.build(); |
|
||||
return enricher.apply(documents); |
|
||||
} |
|
||||
} |
|
||||
@ -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; |
||||
|
} |
||||
@ -0,0 +1,80 @@ |
|||||
|
package com.wok.supportbot.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.*; |
||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize; |
||||
|
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.io.Serial; |
||||
|
import java.io.Serializable; |
||||
|
import java.util.Date; |
||||
|
|
||||
|
/** |
||||
|
* 知识库文档目录表 - 支持分类下的目录树形结构 |
||||
|
*/ |
||||
|
@Data |
||||
|
@Builder |
||||
|
@AllArgsConstructor |
||||
|
@NoArgsConstructor |
||||
|
@TableName("knowledge_folder") |
||||
|
public class KnowledgeFolder implements Serializable { |
||||
|
|
||||
|
@Serial |
||||
|
@TableField(exist = false) |
||||
|
private static final long serialVersionUID = 1L; |
||||
|
|
||||
|
/** |
||||
|
* 目录ID |
||||
|
*/ |
||||
|
@TableId(value = "id", type = IdType.ASSIGN_ID) |
||||
|
@JsonSerialize(using = ToStringSerializer.class) |
||||
|
private Long id; |
||||
|
|
||||
|
/** |
||||
|
* 所属分类ID |
||||
|
*/ |
||||
|
@TableField("category_id") |
||||
|
@JsonSerialize(using = ToStringSerializer.class) |
||||
|
private Long categoryId; |
||||
|
|
||||
|
/** |
||||
|
* 父目录ID,0=该分类下的根目录 |
||||
|
*/ |
||||
|
@TableField("parent_id") |
||||
|
@JsonSerialize(using = ToStringSerializer.class) |
||||
|
private Long parentId; |
||||
|
|
||||
|
/** |
||||
|
* 目录名称 |
||||
|
*/ |
||||
|
@TableField("name") |
||||
|
private String name; |
||||
|
|
||||
|
/** |
||||
|
* 排序权重(越大越靠前) |
||||
|
*/ |
||||
|
@TableField("sort_order") |
||||
|
private Integer sortOrder; |
||||
|
|
||||
|
/** |
||||
|
* 创建时间 |
||||
|
*/ |
||||
|
@TableField(value = "create_time", fill = FieldFill.INSERT) |
||||
|
private Date createTime; |
||||
|
|
||||
|
/** |
||||
|
* 更新时间 |
||||
|
*/ |
||||
|
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) |
||||
|
private Date updateTime; |
||||
|
|
||||
|
/** |
||||
|
* 逻辑删除标志 - false:未删除, true:已删除(逻辑删除) |
||||
|
*/ |
||||
|
@TableField("is_delete") |
||||
|
@TableLogic |
||||
|
private boolean isDelete; |
||||
|
} |
||||
@ -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 基础目录ID(null/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; |
||||
|
} |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue