14 Commits

Author SHA1 Message Date
wanghanlin 06edac2738 feat(frontend): 文档列表位置列显示目录路径并修复一屏滚动布局 2 months ago
wanghanlin 06fbbaa41d chore(frontend): 更新 SDK 测试面板构建产物引用 2 months ago
wanghanlin d0cd06f9fc fix(document): 修复批量上传向量化只成功小文档问题并加固处理链路 2 months ago
wanghanlin 459cabb1bc fix(mcp-server): 修复启用开关触发 toggle/refresh 请求死循环 2 months ago
wanghanlin 9896dec82a feat(frontend): DocList 目录树支持拖拽宽度与更多操作菜单,接入 BaseTable 2 months ago
wanghanlin 97f9befc6d refactor(frontend): 统一列表表格为 BaseTable 组件 2 months ago
wanghanlin 1ea991e5b8 fix(frontend): 全局重置 body margin 消除页面多余滚动 2 months ago
wanghanlin 2668b8e73b feat(document): 文档列表支持移动到目录(统一分类根/目录) 2 months ago
wanghanlin f06422d5e9 docs: 补充开发规范与踩坑记录 2 months ago
wanghanlin 1ffd960377 feat(document): 目录树独立滚动与文件夹上传完善 2 months ago
wanghanlin 562370ece5 fix(backend): 数据库初始化幂等自愈与 vector_store 物理删除 2 months ago
wanghanlin dc8aa7a9d1 refactor(backend): 列表排序字段白名单统一走 SortUtils 防 SQL 注入 2 months ago
wanghanlin 367ec79401 fix(document): 修复文件夹上传连接池耗尽与目录树独立滚动 2 months ago
wanghanlin 780917561b feat(document): 新增知识库文档目录(文件夹)功能 2 months ago
  1. 53
      CLAUDE.md
  2. 9
      README.md
  3. 10
      frontend/UI-DEV-GUIDE.md
  4. 4
      frontend/components.d.ts
  5. 7
      frontend/src/api/api-key.ts
  6. 4
      frontend/src/api/conversation.ts
  7. 11
      frontend/src/api/document.ts
  8. 4
      frontend/src/api/faq.ts
  9. 17
      frontend/src/api/folder.ts
  10. 4
      frontend/src/api/llm-trace.ts
  11. 9
      frontend/src/api/mcp-server.ts
  12. 4
      frontend/src/api/model-config.ts
  13. 16
      frontend/src/api/request.ts
  14. 8
      frontend/src/api/sensitive-word.ts
  15. 5
      frontend/src/api/upload.ts
  16. 4
      frontend/src/api/user.ts
  17. 7
      frontend/src/api/webhook.ts
  18. 40
      frontend/src/components/BaseTable.vue
  19. 7
      frontend/src/styles/shared.css
  20. 15
      frontend/src/types/models.ts
  21. 11
      frontend/src/views/AccountManager.vue
  22. 12
      frontend/src/views/ApiKeyManager.vue
  23. 12
      frontend/src/views/AuditLogManager.vue
  24. 23
      frontend/src/views/CategoryManager.vue
  25. 16
      frontend/src/views/ConversationManager.vue
  26. 6
      frontend/src/views/DashboardPanel.vue
  27. 495
      frontend/src/views/DocList.vue
  28. 192
      frontend/src/views/DocUpload.vue
  29. 18
      frontend/src/views/FaqManager.vue
  30. 18
      frontend/src/views/FeedbackOps.vue
  31. 25
      frontend/src/views/McpServerManager.vue
  32. 20
      frontend/src/views/ModelConfigManager.vue
  33. 52
      frontend/src/views/PromptTracePanel.vue
  34. 20
      frontend/src/views/SensitiveWordManager.vue
  35. 19
      frontend/src/views/UserManager.vue
  36. 12
      frontend/src/views/WebhookManager.vue
  37. 40
      src/main/java/com/wok/supportbot/common/SortUtils.java
  38. 18
      src/main/java/com/wok/supportbot/config/AsyncExecutorConfig.java
  39. 78
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  40. 30
      src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java
  41. 16
      src/main/java/com/wok/supportbot/config/VolcengineMultimodalEmbeddingModel.java
  42. 6
      src/main/java/com/wok/supportbot/controller/AiModelConfigController.java
  43. 6
      src/main/java/com/wok/supportbot/controller/ApiKeyController.java
  44. 6
      src/main/java/com/wok/supportbot/controller/ConversationController.java
  45. 87
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  46. 6
      src/main/java/com/wok/supportbot/controller/FaqController.java
  47. 136
      src/main/java/com/wok/supportbot/controller/FolderController.java
  48. 6
      src/main/java/com/wok/supportbot/controller/LlmCallTraceController.java
  49. 17
      src/main/java/com/wok/supportbot/controller/McpServerConfigController.java
  50. 6
      src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java
  51. 22
      src/main/java/com/wok/supportbot/controller/SensitiveWordController.java
  52. 6
      src/main/java/com/wok/supportbot/controller/SysUserController.java
  53. 6
      src/main/java/com/wok/supportbot/controller/WebhookController.java
  54. 12
      src/main/java/com/wok/supportbot/dao/KnowledgeFolderMapper.java
  55. 30
      src/main/java/com/wok/supportbot/document/transform/MyKeywordEnricher.java
  56. 63
      src/main/java/com/wok/supportbot/entity/FolderNode.java
  57. 7
      src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java
  58. 80
      src/main/java/com/wok/supportbot/entity/KnowledgeFolder.java
  59. 4
      src/main/java/com/wok/supportbot/rag/HybridSearchService.java
  60. 29
      src/main/java/com/wok/supportbot/service/AiModelConfigService.java
  61. 17
      src/main/java/com/wok/supportbot/service/ApiKeyService.java
  62. 24
      src/main/java/com/wok/supportbot/service/ConversationService.java
  63. 437
      src/main/java/com/wok/supportbot/service/DocumentProcessingService.java
  64. 290
      src/main/java/com/wok/supportbot/service/DocumentService.java
  65. 21
      src/main/java/com/wok/supportbot/service/FaqService.java
  66. 313
      src/main/java/com/wok/supportbot/service/FolderService.java
  67. 24
      src/main/java/com/wok/supportbot/service/LlmCallTraceService.java
  68. 21
      src/main/java/com/wok/supportbot/service/McpServerConfigService.java
  69. 22
      src/main/java/com/wok/supportbot/service/MessageFeedbackService.java
  70. 19
      src/main/java/com/wok/supportbot/service/SensitiveWordService.java
  71. 19
      src/main/java/com/wok/supportbot/service/SysUserService.java
  72. 17
      src/main/java/com/wok/supportbot/service/WebhookService.java
  73. 3
      src/main/resources/application.yml
  74. 42
      src/main/resources/init-database.sql
  75. 12
      src/main/resources/static/sdk/test.html

53
CLAUDE.md

@ -67,7 +67,9 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- **Open API**: `OpenApiController` 已接入 `ChatPipeline`,补齐角色/RAG/FAQ/MCP/分类隔离能力 - **Open API**: `OpenApiController` 已接入 `ChatPipeline`,补齐角色/RAG/FAQ/MCP/分类隔离能力
### 文档处理管道 ### 文档处理管道
`DocumentService.uploadDocument()` 统一流程:文档提取 → `MyTokenTextSplitter` 分块 → `MyKeywordEnricher` AI 关键词提取 → `pgVectorVectorStore.add()` 向量化存储。每个分块的 metadata 中注入 `documentId`、`chunkIndex`、`sourceName`、`title` 以关联 `knowledge_document` 表。
`DocumentService.uploadDocument()` 统一流程:文档提取 → `MyTokenTextSplitter` 分块 → 为每块写 metadata → 按批向量化(默认 50 块/批,配置项 `knowledge.vector.batch-size`)`pgVectorVectorStore.add(batch)` 入库。每个分块的 metadata 注入 `documentId`、`chunkIndex`、`sourceName`、`title`、`categoryId`、`enabled` 关联 `knowledge_document` 表。
**向量化加固**(`DocumentProcessingService`):逐批 try-catch 隔离,失败批只记录缺失区间后继续,已入库块保留;`chunk_count` 记实际入库块数,`error_message` 聚合"已入库 x/y 块 + 缺失区间 + 原因";文档级失败自动整体重试至多 2 次(仅瞬时/限流/超时类错误,4xx 不空转),重试前先清残留向量再重建。**无逐块 AI 关键词提取环节**(`MyKeywordEnricher` 已移除,其产出 `excerpt_keywords` 全库无检索消费点)。
## 关键配置 ## 关键配置
@ -148,6 +150,20 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
**反面案例**: `knowledge_document.content_hash` 列仅在 `DatabaseInitConfig` 中迁移,未同步到 `knowledge-base.sql`,导致该脚本变为过时版本。 **反面案例**: `knowledge_document.content_hash` 列仅在 `DatabaseInitConfig` 中迁移,未同步到 `knowledge-base.sql`,导致该脚本变为过时版本。
### 后端:vector_store 物理删除(禁止在原生 SQL 中引用 is_delete)
**规则**: `vector_store` 表由 Spring AI `PgVectorStore` 以 `initializeSchema(true)` 自动建表,默认只有 `id / content / metadata(json) / embedding` 四列;`DatabaseInitConfig` 仅额外补了 `content_tsvector`、`create_time` 两列。因此 **vector_store 只保证有六列**:`id / content / metadata / embedding / content_tsvector / create_time`,**没有 `is_delete`、没有 `update_time`**。
- vector_store 的向量删除走**物理删除**(`pgVectorVectorStore.delete(ids)`),不存在逻辑删除语义,原生 SQL 中**禁止**写 `AND is_delete = false`。
- 凡是引用业务表(`knowledge_document` 等 MyBatis Plus 逻辑删除表)的 SQL 里常用的 `is_delete = false` 过滤,**不得照搬到 vector_store**。
- 对 vector_store 写原生 SQL 前,先确认引用的列在上述六列白名单内。
**反面案例**: `HybridSearchService.keywordSearch()` 从 `knowledge_document` 表查询照搬了 `AND is_delete = false`,但 vector_store 无此列,PostgreSQL 报 `column "is_delete" does not exist`(SQLState 42703),被 Spring 统一翻译为 `BadSqlGrammarException`,前端显示「搜索失败: bad SQL grammar」,且仅关键词/混合模式触发(向量模式走 Spring AI similaritySearch 不执行该 SQL)。
### 后端:数据库初始化一律幂等自愈
**规则**: 新增列/索引/触发器的 init 方法(`DatabaseInitConfig` 内)**不要**「列已存在就 return」,应使用幂等 SQL 组合自愈:`ADD COLUMN IF NOT EXISTS` + 回填(`UPDATE ... WHERE xxx IS NULL`)+ `CREATE INDEX IF NOT EXISTS` + `CREATE OR REPLACE FUNCTION` + `DROP TRIGGER IF EXISTS` + `CREATE TRIGGER`。避免半途失败或对象被手动删除后留下静默缺口(如新文档 `content_tsvector` 恒为 NULL、检索静默返回空)。
### 后端:Long ID 序列化为字符串 ### 后端:Long ID 序列化为字符串
**规则**: 所有雪花算法生成的 Long 类型 ID 字段,必须保证前端收到的是**字符串**而非数字,防止 JS 超过 `Number.MAX_SAFE_INTEGER`(2^53)精度丢失。 **规则**: 所有雪花算法生成的 Long 类型 ID 字段,必须保证前端收到的是**字符串**而非数字,防止 JS 超过 `Number.MAX_SAFE_INTEGER`(2^53)精度丢失。
@ -162,6 +178,41 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
### 后端:敏感字段脱敏 ### 后端:敏感字段脱敏
**规则**: API 返回用户对象前,必须将 `password` 等敏感字段置为 `null`。不得将 BCrypt 哈希暴露给前端。 **规则**: API 返回用户对象前,必须将 `password` 等敏感字段置为 `null`。不得将 BCrypt 哈希暴露给前端。
### 后端:列表排序字段白名单(防 SQL 注入)
**规则**: 任何用户可控的列表排序参数,必须经 `com.wok.supportbot.common.SortUtils` 的白名单映射解析,**严禁**将前端传入的 `sortField` 直接拼接进 `ORDER BY`。
| 场景 | 做法 |
|------|------|
| MyBatis Plus `QueryWrapper` | `String col = SortUtils.resolveColumn(sortField, 白名单Map, "create_time");` 再 `orderByAsc/orderByDesc(col)` |
| `LambdaQueryWrapper` | 其 `orderByAsc/orderByDesc` 只接受 `SFunction`、不接受字符串列名,需改用 `QueryWrapper` 做排序 |
| JdbcTemplate 原生 SQL | 同样用 `resolveColumn` 得到安全列名后拼 `ORDER BY col ASC/DESC` |
| `DISTINCT ON` 查询 | 排序需将原查询包成子查询,在外层按白名单列排序(见 `MessageFeedbackService.listConversationsByFeedback`) |
白名单用 `Map.of(colKey, 列名)` 定义;`resolveColumn` 内部先判空(避免 `Map.of().get(null)` 抛 NPE),未知字段回退默认列(通常 `create_time`);`sortOrder` 仅 `asc` 视为升序,其余一律降序。
**反面案例**: 早期 `DocumentService.listDocuments` 手工写白名单,因未判 `sortField == null` 直接 `Map.of().getOrDefault(sortField, ...)` 触发 NPE,已由 `SortUtils.resolveColumn` 统一规避。
### 后端:Java 文本块拼 SQL 的边界空格陷阱
**规则**: 用 Java 文本块(`"""`)拼 SQL 时,**严禁**让闭合定界符 `"""` 与内容行同尾、或让变量紧跟开启定界符 `"""` 拼接,否则空格/换行会被静默吞掉导致 SQL 粘连。Java 文本块有两个隐蔽行为:
1. **闭合定界符前的尾随空格被剥离**:`ORDER BY """` 中 `BY` 后的空格会丢,`"ORDER BY "` 变成 `"ORDER BY"`。
2. **开启定界符后的前导换行被消费**:`+ """` 后紧跟的下一文本块,其第一行前导换行不算内容,导致前一段末尾与后一段开头直接相连(如 `DESC` + `LIMIT` 变成 `DESCLIMIT`)。
**做法**: 涉及排序子句、动态变量拼接时,用普通字符串显式带空格拼接,不要依赖文本块边界:
```java
// ✅ 正确:显式空格 + 变量,不碰文本块边界
""" ... ) t
""" + " ORDER BY " + orderBy + " LIMIT ? OFFSET ?";
// ❌ 错误:ORDER BY 尾随空格被剥离、orderBy 与 LIMIT 粘连
""" ... ) t
ORDER BY """ + orderBy + """
LIMIT ? OFFSET ?
""";
```
`""" + whereClause` 这类「闭合定界符独立一行 + whereClause 自带前导空格」的写法是安全的,因为不依赖尾随空格。
**反面案例**: `MessageFeedbackService.listConversationsByFeedback` 用 `ORDER BY """ + orderBy + """` 拼接,编译后 `ORDER BY ` 尾随空格被剥离、`DESC` 与 `LIMIT` 粘连成 `ORDER BYfeedback_time DESCLIMIT`,PostgreSQL 报 `syntax error`(Spring 翻译为 `BadSqlGrammarException`),前端显示「查询失败:bad SQL grammar」。
### 前端:弹窗实现统一模式 ### 前端:弹窗实现统一模式
项目中存在两种弹窗模式,**不可混用**: 项目中存在两种弹窗模式,**不可混用**:

9
README.md

@ -188,8 +188,7 @@ src/main/java/com/wok/supportbot/
│ │ ├── JsonDocumentLoader.java # JSON 解析(3种模式) │ │ ├── JsonDocumentLoader.java # JSON 解析(3种模式)
│ │ └── SimpleStringDocumentReader.java # 纯文本读取 │ │ └── SimpleStringDocumentReader.java # 纯文本读取
│ └── transform/ # 文档转换器 │ └── transform/ # 文档转换器
│ ├── MyTokenTextSplitter.java # Token 分块器
│ └── MyKeywordEnricher.java # AI 关键词提取
│ └── MyTokenTextSplitter.java # Token 分块器
├── entity/ # 数据实体类 ├── entity/ # 数据实体类
│ ├── ChatMessage.java # 聊天消息实体 │ ├── ChatMessage.java # 聊天消息实体
│ ├── KnowledgeDocument.java # 知识文档实体 │ ├── KnowledgeDocument.java # 知识文档实体
@ -234,11 +233,11 @@ src/main/resources/
↓ ↓
[Token 分块] MyTokenTextSplitter (200 token / 100 overlap) [Token 分块] MyTokenTextSplitter (200 token / 100 overlap)
↓ ↓
[关键词提取] MyKeywordEnricher (AI 提取 Top-5 关键词)
[元数据标注] metadata.documentId / chunkIndex / sourceName / title / categoryId / enabled
↓ ↓
[向量化存储] DashScope text-embedding-v2 → PGVector
[分批向量化] 默认 50 块/批循环 add → PGVector(逐批隔离,失败批记录区间后继续,整体失败自动重试 ≤2 次)
↓ ↓
[元数据关联] metadata.documentId / chunkIndex / sourceName / title
[状态更新] READY / FAILED(已入库块保留,chunk_count = 实际入库块数,error_message 聚合缺失区间)
``` ```
## 🖥️ 前端管理页面 ## 🖥️ 前端管理页面

10
frontend/UI-DEV-GUIDE.md

@ -133,7 +133,7 @@ import { palette, paletteBg, paletteBorder } from '@/utils/palette'
</div> </div>
<!-- ② 表格 --> <!-- ② 表格 -->
<t-table
<BaseTable
:data="pagination.data.value" :data="pagination.data.value"
:columns="columns" :columns="columns"
:loading="pagination.loading.value" :loading="pagination.loading.value"
@ -152,7 +152,7 @@ import { palette, paletteBg, paletteBorder } from '@/utils/palette'
<t-button size="small" variant="text" theme="danger" @click="onDelete(row)">删除</t-button> <t-button size="small" variant="text" theme="danger" @click="onDelete(row)">删除</t-button>
</t-space> </t-space>
</template> </template>
</t-table>
</BaseTable>
</t-card> </t-card>
<!-- ③ 弹窗表单 --> <!-- ③ 弹窗表单 -->
@ -229,8 +229,10 @@ const columns = [
| 点 | 规范 | | 点 | 规范 |
| --- | --- | | --- | --- |
| 列表表格组件 | 统一用 `BaseTable` 组件(自动 `size="small"` + 悬浮 `hover` + 占满容器宽度 `table-layout="fixed"` + 固定表头 `max-height`);操作列 `colKey:'op'` 自动右固定 `fixed:'right'`,无需手动设置 |
| 表格列定义 | 统一写在 `<script>` 的 `columns` 数组 + `:columns`;**禁用** `<t-table-column>` 标签写法 | | 表格列定义 | 统一写在 `<script>` 的 `columns` 数组 + `:columns`;**禁用** `<t-table-column>` 标签写法 |
| 分页 | 统一用 `<t-table>` 内置 `:pagination` + `@page-change` + `showJumper`;**禁用**独立 `<t-pagination>` 组件 |
| 分页 | 统一用 `<BaseTable>` 内置 `:pagination` + `@page-change` + `showJumper`;**禁用**独立 `<t-pagination>` 组件 |
| 列排序 | 后端分页表格:列 `sorter: true` + 表格 `:sort="sortInfo"` + `@sort-change` 透传服务端 `sortField/sortOrder`;前端全量表格:仅列 `sorter: true`,数值列用 `sorter: (a,b)=>(a.x??0)-(b.x??0)` 函数 |
| 表格 loading | 必须绑定 `:loading`(现状有 4 个页面缺失) | | 表格 loading | 必须绑定 `:loading`(现状有 4 个页面缺失) |
| pageSize | 默认统一 20(现状 10/20/30 三档不一) | | pageSize | 默认统一 20(现状 10/20/30 三档不一) |
| 操作列 | `<t-space :size="4">` + `<t-button size="small" variant="text">`;危险操作 `theme="danger"`、主操作 `theme="primary"` | | 操作列 | `<t-space :size="4">` + `<t-button size="small" variant="text">`;危险操作 `theme="danger"`、主操作 `theme="primary"` |
@ -397,7 +399,7 @@ try {
### 一致性偏差 ### 一致性偏差
8. 页头 3 种写法并存:`title` 属性(多数) vs `#title`+`<h2>`(`ModelConfigManager`) vs `#title`+图标(`SystemConfigManager`)。 8. 页头 3 种写法并存:`title` 属性(多数) vs `#title`+`<h2>`(`ModelConfigManager`) vs `#title`+图标(`SystemConfigManager`)。
9. 表格缺 `:loading`:`UserManager` / `AccountManager` / `McpServerManager` / `ConversationManager`。
9. ~~表格缺 `:loading`:`UserManager` / `AccountManager` / `McpServerManager` / `ConversationManager`。~~(已补齐,且列表表格统一由 `BaseTable` 承载)
10. 分页缺失/不统一:`AccountManager` 无分页;pageSize 10/20/30 三档。 10. 分页缺失/不统一:`AccountManager` 无分页;pageSize 10/20/30 三档。
11. 表单校验分裂:全无 `:rules`,手写 `if` 与 `:disabled` 两种土办法并存。 11. 表单校验分裂:全无 `:rules`,手写 `if` 与 `:disabled` 两种土办法并存。
12. 图标体系分裂:emoji 主导,仅 `SystemConfigManager` 用 tdesign-icons;`ModelConfigManager` 操作列纯 emoji 按钮。 12. 图标体系分裂:emoji 主导,仅 `SystemConfigManager` 用 tdesign-icons;`ModelConfigManager` 操作列纯 emoji 按钮。

4
frontend/components.d.ts

@ -7,6 +7,7 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
BaseTable: typeof import('./src/components/BaseTable.vue')['default']
FormDialog: typeof import('./src/components/FormDialog.vue')['default'] FormDialog: typeof import('./src/components/FormDialog.vue')['default']
FormDrawer: typeof import('./src/components/FormDrawer.vue')['default'] FormDrawer: typeof import('./src/components/FormDrawer.vue')['default']
MessageSources: typeof import('./src/components/MessageSources.vue')['default'] MessageSources: typeof import('./src/components/MessageSources.vue')['default']
@ -24,6 +25,7 @@ declare module 'vue' {
TDialog: typeof import('tdesign-vue-next')['Dialog'] TDialog: typeof import('tdesign-vue-next')['Dialog']
TDivider: typeof import('tdesign-vue-next')['Divider'] TDivider: typeof import('tdesign-vue-next')['Divider']
TDrawer: typeof import('tdesign-vue-next')['Drawer'] TDrawer: typeof import('tdesign-vue-next')['Drawer']
TDropdown: typeof import('tdesign-vue-next')['Dropdown']
TEmpty: typeof import('tdesign-vue-next')['Empty'] TEmpty: typeof import('tdesign-vue-next')['Empty']
TForm: typeof import('tdesign-vue-next')['Form'] TForm: typeof import('tdesign-vue-next')['Form']
TFormItem: typeof import('tdesign-vue-next')['FormItem'] TFormItem: typeof import('tdesign-vue-next')['FormItem']
@ -47,6 +49,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']
} }
} }

7
frontend/src/api/api-key.ts

@ -1,8 +1,11 @@
import request from './request' import request from './request'
import type { ApiResponse } from '@/types/api' import type { ApiResponse } from '@/types/api'
export function listApiKeys(page = 1, size = 20): Promise<ApiResponse> {
return request.get(`/api-key/list?page=${page}&size=${size}`).then(r => r.data)
export function listApiKeys(page = 1, size = 20, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/api-key/list?page=${page}&size=${size}`
if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}`
return request.get(path).then(r => r.data)
} }
/** 拉取全部 API Key(供筛选下拉,最多 1000 条) */ /** 拉取全部 API Key(供筛选下拉,最多 1000 条) */
export function listAllApiKeys(): Promise<ApiResponse> { export function listAllApiKeys(): Promise<ApiResponse> {

4
frontend/src/api/conversation.ts

@ -1,11 +1,13 @@
import request from './request' import request from './request'
import type { ApiResponse } from '@/types/api' import type { ApiResponse } from '@/types/api'
export function listConversations(page = 1, size = 10, keyword?: string, accountId?: string, roleId?: string): Promise<ApiResponse> {
export function listConversations(page = 1, size = 10, keyword?: string, accountId?: string, roleId?: string, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/conversation/list?page=${page}&size=${size}` let path = `/conversation/list?page=${page}&size=${size}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (accountId) path += `&accountId=${encodeURIComponent(accountId)}` if (accountId) path += `&accountId=${encodeURIComponent(accountId)}`
if (roleId) path += `&roleId=${encodeURIComponent(roleId)}` if (roleId) path += `&roleId=${encodeURIComponent(roleId)}`
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)
} }
export function getConversationDetail(conversationId: string): Promise<ApiResponse> { return request.get(`/conversation/${conversationId}`).then(r => r.data) } export function getConversationDetail(conversationId: string): Promise<ApiResponse> { return request.get(`/conversation/${conversationId}`).then(r => r.data) }

11
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)
} }
@ -95,9 +98,9 @@ export function batchToggleDocuments(ids: string[], enabled: boolean): Promise<A
return request.post('/document/batch/toggle', { ids, enabled }).then(r => r.data) return request.post('/document/batch/toggle', { ids, enabled }).then(r => r.data)
} }
/** 批量移动分类 */
export function batchMoveDocuments(ids: string[], categoryId: string): Promise<ApiResponse> {
return request.post('/document/batch/move', { ids, categoryId }).then(r => r.data)
/** 批量移动(分类根或目录,folderId 为空/0 表示移动到分类根) */
export function batchMoveDocuments(ids: string[], categoryId: string, folderId?: string): Promise<ApiResponse> {
return request.post('/document/batch/move', { ids, categoryId, folderId }).then(r => r.data)
} }
/** 更新单个分块内容 */ /** 更新单个分块内容 */

4
frontend/src/api/faq.ts

@ -1,11 +1,13 @@
import request from './request' import request from './request'
import type { ApiResponse } from '@/types/api' import type { ApiResponse } from '@/types/api'
export function listFaqs(page = 1, size = 20, keyword?: string, categoryId?: string, status?: string): Promise<ApiResponse> {
export function listFaqs(page = 1, size = 20, keyword?: string, categoryId?: string, status?: string, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/faq/list?page=${page}&size=${size}` let path = `/faq/list?page=${page}&size=${size}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (categoryId) path += `&categoryId=${categoryId}` if (categoryId) path += `&categoryId=${categoryId}`
if (status) path += `&status=${encodeURIComponent(status)}` if (status) path += `&status=${encodeURIComponent(status)}`
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)
} }
export function createFaq(data: any): Promise<ApiResponse> { return request.post('/faq', data).then(r => r.data) } export function createFaq(data: any): Promise<ApiResponse> { return request.post('/faq', data).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) }

4
frontend/src/api/llm-trace.ts

@ -13,6 +13,8 @@ export interface LlmTraceQuery {
keyword?: string keyword?: string
apiKeyId?: string apiKeyId?: string
errorType?: string errorType?: string
sortField?: string
sortOrder?: string
} }
/** 分页查询调用记录 */ /** 分页查询调用记录 */
@ -28,6 +30,8 @@ export function listLlmTraces(query: LlmTraceQuery = {}): Promise<ApiResponse> {
if (query.keyword) params.set('keyword', query.keyword) if (query.keyword) params.set('keyword', query.keyword)
if (query.apiKeyId) params.set('apiKeyId', query.apiKeyId) if (query.apiKeyId) params.set('apiKeyId', query.apiKeyId)
if (query.errorType) params.set('errorType', query.errorType) if (query.errorType) params.set('errorType', query.errorType)
if (query.sortField) params.set('sortField', query.sortField)
if (query.sortOrder) params.set('sortOrder', query.sortOrder)
return request.get(`/llm-trace/list?${params.toString()}`).then(r => r.data) return request.get(`/llm-trace/list?${params.toString()}`).then(r => r.data)
} }

9
frontend/src/api/mcp-server.ts

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

4
frontend/src/api/model-config.ts

@ -1,9 +1,11 @@
import request from './request' import request from './request'
import type { ApiResponse } from '@/types/api' import type { ApiResponse } from '@/types/api'
export function listModelConfigs(page = 1, size = 10, appType?: string): Promise<ApiResponse> {
export function listModelConfigs(page = 1, size = 10, appType?: string, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/model-config/list?page=${page}&size=${size}` let path = `/model-config/list?page=${page}&size=${size}`
if (appType) path += `&appType=${encodeURIComponent(appType)}` if (appType) path += `&appType=${encodeURIComponent(appType)}`
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)
} }
export function getModelConfigDetail(id: string): Promise<ApiResponse> { return request.get(`/model-config/${id}`).then(r => r.data) } export function getModelConfigDetail(id: string): Promise<ApiResponse> { return request.get(`/model-config/${id}`).then(r => r.data) }

16
frontend/src/api/request.ts

@ -42,6 +42,15 @@ request.interceptors.response.use(
const url: string = config?.url || '' const url: string = config?.url || ''
if (response?.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) { if (response?.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) {
// 重试上限:已经过「401 → refresh → 重试」一次的请求若仍 401,不再二次刷新,
// 直接失败,斩断 401→refresh→401 的自激死循环(配合 _retry 标记,见下方挂起分支)
if ((config as any)._retry) {
return Promise.reject(new Error(response?.data?.message || error.message || '登录已过期,请重新登录'))
}
// 标记本次请求已进入刷新流程(含挂起等待者),确保每个请求至多重试一次
const retriedConfig = config as any
retriedConfig._retry = true
// 已有刷新在进行中,挂起当前请求,等待新 token 后重试 // 已有刷新在进行中,挂起当前请求,等待新 token 后重试
if (isRefreshing) { if (isRefreshing) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -55,17 +64,18 @@ request.interceptors.response.use(
}) })
} }
// 发起刷新(request 的 url 为 /auth/refresh,其 401 会被上方判断跳过,不会递归)
// 发起刷新(用「裸 axios」而不走当前实例,避免 refresh 自身再次进入本拦截器造成递归;
// 其 401 也会被上方 url 判断跳过,不会二次刷新)
isRefreshing = true isRefreshing = true
try { try {
const res = await request.post('/auth/refresh')
const res = await axios.post('/auth/refresh', null, { withCredentials: true })
const newToken = res.data?.data?.accessToken const newToken = res.data?.data?.accessToken
if (newToken) { if (newToken) {
setToken(newToken) setToken(newToken)
// 放行所有挂起请求(请求拦截器会自动附加新 token) // 放行所有挂起请求(请求拦截器会自动附加新 token)
pendingQueue.forEach((cb) => cb(newToken)) pendingQueue.forEach((cb) => cb(newToken))
pendingQueue = [] pendingQueue = []
// 重试当前请求
// 重试当前请求(config._retry 已为 true,若仍 401 会直接失败而不再刷新)
return request(config) return request(config)
} }
throw new Error('刷新令牌失败') throw new Error('刷新令牌失败')

8
frontend/src/api/sensitive-word.ts

@ -1,11 +1,13 @@
import request from './request' import request from './request'
import type { ApiResponse } from '@/types/api' import type { ApiResponse } from '@/types/api'
export function listSensitiveWords(page = 1, size = 20, keyword?: string, category?: string, level?: number): Promise<ApiResponse> {
export function listSensitiveWords(page = 1, size = 20, keyword?: string, category?: string, level?: number, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/sensitive-word/list?page=${page}&size=${size}` let path = `/sensitive-word/list?page=${page}&size=${size}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (category) path += `&category=${encodeURIComponent(category)}` if (category) path += `&category=${encodeURIComponent(category)}`
if (level !== undefined && level !== null) path += `&level=${level}` if (level !== undefined && level !== null) path += `&level=${level}`
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)
} }
export function createSensitiveWord(data: any): Promise<ApiResponse> { return request.post('/sensitive-word', data).then(r => r.data) } export function createSensitiveWord(data: any): Promise<ApiResponse> { return request.post('/sensitive-word', data).then(r => r.data) }
@ -14,8 +16,10 @@ export function updateSensitiveWord(id: string, data: any): Promise<ApiResponse>
export function toggleSensitiveWord(id: string, isActive: boolean): Promise<ApiResponse> { return updateSensitiveWord(id, { isActive }) } export function toggleSensitiveWord(id: string, isActive: boolean): Promise<ApiResponse> { return updateSensitiveWord(id, { isActive }) }
export function deleteSensitiveWord(id: string): Promise<ApiResponse> { return request.delete(`/sensitive-word/${id}`).then(r => r.data) } export function deleteSensitiveWord(id: string): Promise<ApiResponse> { return request.delete(`/sensitive-word/${id}`).then(r => r.data) }
export function batchImportSensitiveWords(data: { words: string[]; category: string; level: number }): Promise<ApiResponse> { return request.post('/sensitive-word/batch-import', data).then(r => r.data) } export function batchImportSensitiveWords(data: { words: string[]; category: string; level: number }): Promise<ApiResponse> { return request.post('/sensitive-word/batch-import', data).then(r => r.data) }
export function listAuditLogs(page = 1, size = 20, sessionId?: string): Promise<ApiResponse> {
export function listAuditLogs(page = 1, size = 20, sessionId?: string, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/sensitive-word/audit-log?page=${page}&size=${size}` let path = `/sensitive-word/audit-log?page=${page}&size=${size}`
if (sessionId) path += `&sessionId=${encodeURIComponent(sessionId)}` if (sessionId) path += `&sessionId=${encodeURIComponent(sessionId)}`
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)
} }

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)

4
frontend/src/api/user.ts

@ -1,10 +1,12 @@
import request from './request' import request from './request'
import type { ApiResponse } from '@/types/api' import type { ApiResponse } from '@/types/api'
export function listSysUsers(page = 1, size = 20, keyword?: string, enabled?: boolean): Promise<ApiResponse> {
export function listSysUsers(page = 1, size = 20, keyword?: string, enabled?: boolean, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/sys-user/list?page=${page}&size=${size}` let path = `/sys-user/list?page=${page}&size=${size}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (enabled !== undefined && enabled !== null) path += `&enabled=${enabled}` if (enabled !== undefined && enabled !== null) path += `&enabled=${enabled}`
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)
} }
export function getSysUserDetail(id: string): Promise<ApiResponse> { return request.get(`/sys-user/${id}`).then(r => r.data) } export function getSysUserDetail(id: string): Promise<ApiResponse> { return request.get(`/sys-user/${id}`).then(r => r.data) }

7
frontend/src/api/webhook.ts

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

40
frontend/src/components/BaseTable.vue

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

7
frontend/src/styles/shared.css

@ -6,6 +6,13 @@
* 不再散落硬编码 hex,保证全站配色一致。 * 不再散落硬编码 hex,保证全站配色一致。
*/ */
/* ====== 全局基础重置 ====== */
* { box-sizing: border-box; }
html, body, #app {
margin: 0;
padding: 0;
}
/* ====== 工具栏 ====== */ /* ====== 工具栏 ====== */
.toolbar { .toolbar {
display: flex; display: flex;

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 条目 */

11
frontend/src/views/AccountManager.vue

@ -5,7 +5,7 @@
<t-button theme="primary" size="small" @click="openCreate">+ 新建账号</t-button> <t-button theme="primary" size="small" @click="openCreate">+ 新建账号</t-button>
<t-button variant="outline" size="small" @click="loadList">🔄 刷新</t-button> <t-button variant="outline" size="small" @click="loadList">🔄 刷新</t-button>
</div> </div>
<t-table :data="accounts" :columns="columns" row-key="id">
<BaseTable :data="accounts" :columns="columns" row-key="id" :loading="loading">
<template #roles="{row}"><t-tag v-for="r in (row.roleNames||[])" :key="r" size="small" variant="light" theme="primary" style="margin-right:4px;">{{ r }}</t-tag></template> <template #roles="{row}"><t-tag v-for="r in (row.roleNames||[])" :key="r" size="small" variant="light" theme="primary" style="margin-right:4px;">{{ r }}</t-tag></template>
<template #op="{row}"> <template #op="{row}">
<t-space :size="4"> <t-space :size="4">
@ -13,7 +13,7 @@
<t-button size="small" variant="text" theme="danger" @click="doDelete(row.id)">删除</t-button> <t-button size="small" variant="text" theme="danger" @click="doDelete(row.id)">删除</t-button>
</t-space> </t-space>
</template> </template>
</t-table>
</BaseTable>
<t-dialog v-model:visible="modal.visible" :header="modal.isEdit?'编辑账号':'新建账号'" width="480px" :footer="false"> <t-dialog v-model:visible="modal.visible" :header="modal.isEdit?'编辑账号':'新建账号'" width="480px" :footer="false">
<t-form label-align="top"> <t-form label-align="top">
@ -35,15 +35,16 @@ import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm() const { confirm } = useConfirm()
const accounts=ref<any[]>([])
const accounts=ref<any[]>([]); const loading=ref(false)
const columns=[{colKey:'name',title:'账号名称',width:160},{colKey:'roles',title:'绑定角色',width:240},{colKey:'op',title:'操作',width:120}]
const columns=[{colKey:'name',title:'账号名称',width:160,sorter:true},{colKey:'roles',title:'绑定角色',width:240,sorter:true},{colKey:'op',title:'操作',width:120}]
const modal=reactive({visible:false,isEdit:false,id:null as any,name:''}) const modal=reactive({visible:false,isEdit:false,id:null as any,name:''})
onMounted(()=>loadList()) onMounted(()=>loadList())
async function loadList(){try{const r=await getAccountList();if(r.success)accounts.value=r.data||[]}catch(e:any){toast('加载失败:'+e.message,'error')}}
async function loadList(){loading.value=true
try{const r=await getAccountList();if(r.success)accounts.value=r.data||[]}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
function openCreate(){Object.assign(modal,{visible:true,isEdit:false,id:null,name:''})} function openCreate(){Object.assign(modal,{visible:true,isEdit:false,id:null,name:''})}
function openEdit(a:any){Object.assign(modal,{visible:true,isEdit:true,id:a.id,name:a.name||''})} function openEdit(a:any){Object.assign(modal,{visible:true,isEdit:true,id:a.id,name:a.name||''})}

12
frontend/src/views/ApiKeyManager.vue

@ -5,8 +5,8 @@
<div style="flex:1;" /><span class="total-hint">共 {{ total }} 个 Key</span> <div style="flex:1;" /><span class="total-hint">共 {{ total }} 个 Key</span>
</div> </div>
<t-table :data="keys" :columns="columns" row-key="id" :loading="loading"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange">
<BaseTable :data="keys" :columns="columns" row-key="id" :loading="loading" :sort="sortInfo"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange" @sort-change="onSortChange">
<template #keyValue="{row}"> <template #keyValue="{row}">
<code class="key-code">{{ maskKey(row.keyValue) }}</code> <code class="key-code">{{ maskKey(row.keyValue) }}</code>
<t-button size="small" variant="text" @click="copyKey(row.keyValue)" title="复制">📋</t-button> <t-button size="small" variant="text" @click="copyKey(row.keyValue)" title="复制">📋</t-button>
@ -25,7 +25,7 @@
<t-button size="small" variant="text" theme="danger" @click="doDelete(row.id)">删除</t-button> <t-button size="small" variant="text" theme="danger" @click="doDelete(row.id)">删除</t-button>
</t-space> </t-space>
</template> </template>
</t-table>
</BaseTable>
<!-- 新建弹窗 --> <!-- 新建弹窗 -->
<t-dialog v-model:visible="showCreateDialog" header="新建 API Key" width="520px" :footer="false"> <t-dialog v-model:visible="showCreateDialog" header="新建 API Key" width="520px" :footer="false">
@ -95,7 +95,8 @@ const form=ref({name:'',description:'',rateLimit:60})
const createdKey=ref('') const createdKey=ref('')
const allRoles=ref<any[]>([]);const selectedRoleIds=ref<string[]>([]);const bindingKeyId=ref<any>(null);const bindingKeyName=ref('') const allRoles=ref<any[]>([]);const selectedRoleIds=ref<string[]>([]);const bindingKeyId=ref<any>(null);const bindingKeyName=ref('')
const columns=[{colKey:'name',title:'名称',width:140},{colKey:'keyValue',title:'Key',width:200},{colKey:'rateLimit',title:'频率限制',width:80},{colKey:'roleIds',title:'角色',width:90},{colKey:'enabled',title:'状态',width:80},{colKey:'op',title:'操作',width:260}]
const columns=[{colKey:'name',title:'名称',width:140,sorter:true},{colKey:'keyValue',title:'Key',width:200},{colKey:'rateLimit',title:'频率限制',width:80,sorter:true},{colKey:'roleIds',title:'角色',width:90},{colKey:'enabled',title:'状态',width:80,sorter:true},{colKey:'op',title:'操作',width:260}]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
onMounted(()=>{loadList();loadRoles()}) onMounted(()=>{loadList();loadRoles()})
@ -103,8 +104,9 @@ function maskKey(k:string){if(!k||k.length<12)return k||'—';return k.slice(0,6
function getBoundRoleCount(ids:any){try{const a=typeof ids==='string'?JSON.parse(ids):ids;return Array.isArray(a)?a.length:0}catch{return 0}} function getBoundRoleCount(ids:any){try{const a=typeof ids==='string'?JSON.parse(ids):ids;return Array.isArray(a)?a.length:0}catch{return 0}}
async function loadList(){loading.value=true async function loadList(){loading.value=true
try{const r=await listApiKeys(page.value,pageSize.value);if(r.success){keys.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
try{const r=await listApiKeys(page.value,pageSize.value,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined);if(r.success){keys.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadList()} function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadList()}
function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;loadList()}
async function loadRoles(){try{const r=await getAllRoles();if(r.success)allRoles.value=r.data||[]}catch(e){console.error(e)}} async function loadRoles(){try{const r=await getAllRoles();if(r.success)allRoles.value=r.data||[]}catch(e){console.error(e)}}

12
frontend/src/views/AuditLogManager.vue

@ -1,13 +1,13 @@
<template> <template>
<t-card title="📋 内容审计日志" :bordered="false"> <t-card title="📋 内容审计日志" :bordered="false">
<p class="desc-text">记录所有敏感词命中事件,包含输入/输出方向、违规内容、命中词及处理方式。</p> <p class="desc-text">记录所有敏感词命中事件,包含输入/输出方向、违规内容、命中词及处理方式。</p>
<t-table :data="logs" :columns="columns" row-key="id" :loading="loading"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange">
<BaseTable :data="logs" :columns="columns" row-key="id" :loading="loading" :sort="sortInfo"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange" @sort-change="onSortChange">
<template #direction="{row}"><span :style="{color:row.direction==='INPUT'?'var(--color-primary)':'var(--color-success)'}">{{ row.direction==='INPUT'?'输入':'输出' }}</span></template> <template #direction="{row}"><span :style="{color:row.direction==='INPUT'?'var(--color-primary)':'var(--color-success)'}">{{ row.direction==='INPUT'?'输入':'输出' }}</span></template>
<template #originalText="{row}"><span :title="row.originalText" class="ellipsis-td">{{ row.originalText }}</span></template> <template #originalText="{row}"><span :title="row.originalText" class="ellipsis-td">{{ row.originalText }}</span></template>
<template #actionTaken="{row}"><span :style="{color:row.actionTaken==='BLOCK'?'var(--color-error)':row.actionTaken==='MASK'?'var(--color-warning)':'var(--color-success)'}">{{ row.actionTaken==='BLOCK'?'拦截':row.actionTaken==='MASK'?'脱敏':'通过' }}</span></template> <template #actionTaken="{row}"><span :style="{color:row.actionTaken==='BLOCK'?'var(--color-error)':row.actionTaken==='MASK'?'var(--color-warning)':'var(--color-success)'}">{{ row.actionTaken==='BLOCK'?'拦截':row.actionTaken==='MASK'?'脱敏':'通过' }}</span></template>
<template #hitWords="{row}"><span class="hit-words">{{ formatHitWords(row.hitWords) }}</span></template> <template #hitWords="{row}"><span class="hit-words">{{ formatHitWords(row.hitWords) }}</span></template>
</t-table>
</BaseTable>
</t-card> </t-card>
</template> </template>
@ -19,14 +19,16 @@ import { formatDate } from '@/utils/format'
const logs=ref<any[]>([]);const loading=ref(false);const page=ref(1);const pageSize=ref(30);const total=ref(0) const logs=ref<any[]>([]);const loading=ref(false);const page=ref(1);const pageSize=ref(30);const total=ref(0)
const columns=[{colKey:'createTime',title:'时间',width:160,cell:(_:any,{row}:any)=>formatDate(row.createTime)},{colKey:'direction',title:'方向',width:60},{colKey:'originalText',title:'违规内容',width:260},{colKey:'hitWords',title:'命中词',width:180},{colKey:'actionTaken',title:'处理方式',width:80}]
const columns=[{colKey:'createTime',title:'时间',width:160,sorter:true,cell:(_:any,{row}:any)=>formatDate(row.createTime)},{colKey:'direction',title:'方向',width:60,sorter:true},{colKey:'originalText',title:'违规内容',width:260},{colKey:'hitWords',title:'命中词',width:180},{colKey:'actionTaken',title:'处理方式',width:80,sorter:true}]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
onMounted(()=>loadLogs()) onMounted(()=>loadLogs())
async function loadLogs(){loading.value=true async function loadLogs(){loading.value=true
try{const r=await listAuditLogs(page.value,pageSize.value);if(r.success){logs.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}else toast('加载失败:'+(r.message||''),'error')}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
try{const r=await listAuditLogs(page.value,pageSize.value,undefined,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined);if(r.success){logs.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}else toast('加载失败:'+(r.message||''),'error')}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadLogs()} function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadLogs()}
function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;loadLogs()}
function formatHitWords(hw:any):string{ function formatHitWords(hw:any):string{
if(!hw)return'';if(hw.type==='jsonb'&&hw.value){try{hw=JSON.parse(hw.value)}catch{return hw.value}} if(!hw)return'';if(hw.type==='jsonb'&&hw.value){try{hw=JSON.parse(hw.value)}catch{return hw.value}}

23
frontend/src/views/CategoryManager.vue

@ -4,10 +4,8 @@
<div class="create-bar card-box"> <div class="create-bar card-box">
<h3 class="create-title">创建新分类</h3> <h3 class="create-title">创建新分类</h3>
<t-space size="small" style="flex-wrap:wrap;"> <t-space size="small" style="flex-wrap:wrap;">
<t-select v-model="newParentId" :options="parentOpts" placeholder="顶级分类" size="small" style="width:180px;" />
<t-input v-model="newName" placeholder="分类名称" size="small" style="width:160px;" @enter="create" /> <t-input v-model="newName" placeholder="分类名称" size="small" style="width:160px;" @enter="create" />
<t-input v-model="newDescription" placeholder="描述(可选)" size="small" style="width:180px;" /> <t-input v-model="newDescription" placeholder="描述(可选)" size="small" style="width:180px;" />
<t-input-number v-model="newSortOrder" placeholder="排序" size="small" style="width:80px;" :min="0" />
<t-button theme="success" size="small" @click="create">创建</t-button> <t-button theme="success" size="small" @click="create">创建</t-button>
</t-space> </t-space>
</div> </div>
@ -39,7 +37,6 @@
<t-form label-align="top"> <t-form label-align="top">
<t-form-item label="分类名称"><t-input v-model="editModal.name" placeholder="分类名称" /></t-form-item> <t-form-item label="分类名称"><t-input v-model="editModal.name" placeholder="分类名称" /></t-form-item>
<t-form-item label="描述"><t-input v-model="editModal.description" placeholder="描述(可选)" /></t-form-item> <t-form-item label="描述"><t-input v-model="editModal.description" placeholder="描述(可选)" /></t-form-item>
<t-form-item label="排序权重"><t-input-number v-model="editModal.sortOrder" :min="0" style="width:120px;" /></t-form-item>
</t-form> </t-form>
<div class="dialog-footer"> <div class="dialog-footer">
<t-button variant="outline" @click="closeEdit">取消</t-button> <t-button variant="outline" @click="closeEdit">取消</t-button>
@ -60,16 +57,10 @@ const { confirm } = useConfirm()
const categoryStore = useCategoryStore() const categoryStore = useCategoryStore()
const newName=ref(''); const newDescription=ref(''); const newParentId=ref(''); const newSortOrder=ref(0)
const parentOpts = computed(() => {
const opts = [{label:'顶级分类',value:''}]
function walk(cats: any[], level: number) { for(const c of cats){ opts.push({label:'─'.repeat(level)+' '+c.name,value:String(c.id)}); if(c.children?.length && level<2) walk(c.children,level+1) } }
walk(categoryTree.value,0); return opts
})
const newName=ref(''); const newDescription=ref('')
const expandedIds = ref(new Set<string>()) const expandedIds = ref(new Set<string>())
const editModal = ref({visible:false,id:null as any,name:'',description:'',sortOrder:0})
const editModal = ref({visible:false,id:null as any,name:'',description:''})
const categoryTree = computed(() => { const categoryTree = computed(() => {
const cats = categoryStore.categories||[]; const m=new Map<string,any>(); const roots:any[]=[] const cats = categoryStore.categories||[]; const m=new Map<string,any>(); const roots:any[]=[]
@ -95,14 +86,14 @@ function expandAll(){const s=new Set<string>();function walk(nodes:any[]){for(co
onMounted(async () => { await categoryStore.loadCategories(); setTimeout(expandAll,100) }) onMounted(async () => { await categoryStore.loadCategories(); setTimeout(expandAll,100) })
async function create(){if(!newName.value.trim()){toast('请输入分类名称','error');return} async function create(){if(!newName.value.trim()){toast('请输入分类名称','error');return}
try{const r=await createCategory({name:newName.value.trim(),description:newDescription.value.trim(),parentId:newParentId.value||null,sortOrder:newSortOrder.value})
if(r.success){toast('分类创建成功','success');newName.value='';newDescription.value='';newParentId.value='';newSortOrder.value=0;await categoryStore.loadCategories();setTimeout(expandAll,100)}else toast(r.message||'创建失败','error')}catch(e:any){toast('创建失败:'+e.message,'error')}}
try{const r=await createCategory({name:newName.value.trim(),description:newDescription.value.trim()})
if(r.success){toast('分类创建成功','success');newName.value='';newDescription.value='';await categoryStore.loadCategories();setTimeout(expandAll,100)}else toast(r.message||'创建失败','error')}catch(e:any){toast('创建失败:'+e.message,'error')}}
function openEdit(node:any){editModal.value={visible:true,id:node.id,name:node.name,description:node.description||'',sortOrder:node.sortOrder||0}}
function closeEdit(){editModal.value={visible:false,id:null,name:'',description:'',sortOrder:0}}
function openEdit(node:any){editModal.value={visible:true,id:node.id,name:node.name,description:node.description||''}}
function closeEdit(){editModal.value={visible:false,id:null,name:'',description:''}}
async function saveEdit(){if(!editModal.value.name.trim()){toast('请输入分类名称','error');return} async function saveEdit(){if(!editModal.value.name.trim()){toast('请输入分类名称','error');return}
try{const r=await updateCategory(editModal.value.id,{name:editModal.value.name.trim(),description:editModal.value.description.trim(),sortOrder:editModal.value.sortOrder})
try{const r=await updateCategory(editModal.value.id,{name:editModal.value.name.trim(),description:editModal.value.description.trim()})
if(r.success){toast('分类更新成功','success');closeEdit();await categoryStore.loadCategories();setTimeout(expandAll,100)}else toast(r.message||'更新失败','error')}catch(e:any){toast('更新失败:'+e.message,'error')}} if(r.success){toast('分类更新成功','success');closeEdit();await categoryStore.loadCategories();setTimeout(expandAll,100)}else toast(r.message||'更新失败','error')}catch(e:any){toast('更新失败:'+e.message,'error')}}
async function remove(node:any){const cc=countDescendants(node);const m=cc>0?`\n注意:下有 ${cc} 个子分类`:'' async function remove(node:any){const cc=countDescendants(node);const m=cc>0?`\n注意:下有 ${cc} 个子分类`:''

16
frontend/src/views/ConversationManager.vue

@ -17,8 +17,8 @@
<t-button variant="outline" size="small" @click="load()">刷新</t-button> <t-button variant="outline" size="small" @click="load()">刷新</t-button>
</div> </div>
<t-table :data="conversations" :columns="columns" row-key="conversationId"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange">
<BaseTable :data="conversations" :columns="columns" row-key="conversationId" :loading="loading" :sort="sortInfo"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange" @sort-change="onSortChange">
<template #op="{row}"> <template #op="{row}">
<t-space :size="4"> <t-space :size="4">
<t-button size="small" variant="text" @click="viewMessages(row.conversationId)">查看消息</t-button> <t-button size="small" variant="text" @click="viewMessages(row.conversationId)">查看消息</t-button>
@ -26,7 +26,7 @@
<t-button size="small" variant="text" theme="danger" @click="remove(row.conversationId)">删除</t-button> <t-button size="small" variant="text" theme="danger" @click="remove(row.conversationId)">删除</t-button>
</t-space> </t-space>
</template> </template>
</t-table>
</BaseTable>
<!-- 消息详情弹窗 --> <!-- 消息详情弹窗 -->
<t-dialog v-model:visible="msgModal.visible" header="会话消息详情" width="900px" :footer="false"> <t-dialog v-model:visible="msgModal.visible" header="会话消息详情" width="900px" :footer="false">
@ -87,7 +87,7 @@ import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm() const { confirm } = useConfirm()
const conversations=ref<any[]>([]);const page=ref(1);const pageSize=ref(10);const total=ref(0)
const conversations=ref<any[]>([]);const page=ref(1);const pageSize=ref(10);const total=ref(0);const loading=ref(false)
const keyword=ref('');const accountFilter=ref('');const roleFilter=ref('') const keyword=ref('');const accountFilter=ref('');const roleFilter=ref('')
const roles=ref<any[]>([]);const stats=ref<any>(null) const roles=ref<any[]>([]);const stats=ref<any>(null)
const msgModal=ref({visible:false,conversationId:'',roleName:'',messages:[] as any[]}) const msgModal=ref({visible:false,conversationId:'',roleName:'',messages:[] as any[]})
@ -112,7 +112,8 @@ const canSubmitFaq = computed(() => {
return false return false
}) })
const columns=[{colKey:'conversationId',title:'会话ID',width:160,ellipsis:true},{colKey:'accountId',title:'外部用户ID',width:100},{colKey:'roleName',title:'角色',width:80},{colKey:'messageCount',title:'消息数',width:60},{colKey:'lastMessageTime',title:'最后消息时间',width:140,cell:(_:any,{row}:any)=>formatDate(row.lastMessageTime)},{colKey:'lastMessagePreview',title:'最后消息预览',width:250,ellipsis:true},{colKey:'op',title:'操作',width:200}]
const columns=[{colKey:'conversationId',title:'会话ID',width:160,ellipsis:true,sorter:true},{colKey:'accountId',title:'外部用户ID',width:100,sorter:true},{colKey:'roleName',title:'角色',width:80,sorter:true},{colKey:'messageCount',title:'消息数',width:60,sorter:true},{colKey:'lastMessageTime',title:'最后消息时间',width:140,sorter:true,cell:(_:any,{row}:any)=>formatDate(row.lastMessageTime)},{colKey:'lastMessagePreview',title:'最后消息预览',width:250,ellipsis:true},{colKey:'op',title:'操作',width:200}]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
onMounted(()=>{loadFilters();load();loadStats();loadCategories()}) onMounted(()=>{loadFilters();load();loadStats();loadCategories()})
@ -130,9 +131,10 @@ function reasonLabel(c: string): string {
return map[c] || c return map[c] || c
} }
async function load(p=1){page.value=p
try{const r=await listConversations(p,pageSize.value,keyword.value||undefined,accountFilter.value||undefined,roleFilter.value||undefined);if(r.success){conversations.value=r.data||[];total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}}
async function load(p=1){page.value=p;loading.value=true
try{const r=await listConversations(p,pageSize.value,keyword.value||undefined,accountFilter.value||undefined,roleFilter.value||undefined,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined);if(r.success){conversations.value=r.data||[];total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
function onPageChange(i:{current:number;pageSize:number}){pageSize.value=i.pageSize;load(i.current)} function onPageChange(i:{current:number;pageSize:number}){pageSize.value=i.pageSize;load(i.current)}
function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;load(1)}
async function loadFilters(){try{const r=await getRoleList();if(r.success){roles.value=r.data||[];roleOpts.value=[{label:'全部角色',value:''},...roles.value.map((ro:any)=>({label:ro.name,value:String(ro.id)}))]}}catch(e){console.error(e)}} async function loadFilters(){try{const r=await getRoleList();if(r.success){roles.value=r.data||[];roleOpts.value=[{label:'全部角色',value:''},...roles.value.map((ro:any)=>({label:ro.name,value:String(ro.id)}))]}}catch(e){console.error(e)}}

6
frontend/src/views/DashboardPanel.vue

@ -34,7 +34,7 @@
<t-row :gutter="16"> <t-row :gutter="16">
<t-col :span="6"> <t-col :span="6">
<t-card title="📚 文档命中排行 TOP-10" :bordered="true"> <t-card title="📚 文档命中排行 TOP-10" :bordered="true">
<t-table :data="topHitDocuments" :columns="hitColumns" row-key="idx" v-if="topHitDocuments.length" />
<BaseTable :data="topHitDocuments" :columns="hitColumns" row-key="idx" v-if="topHitDocuments.length" :max-height="''" />
<t-empty v-else description="暂无数据" /> <t-empty v-else description="暂无数据" />
</t-card> </t-card>
</t-col> </t-col>
@ -79,8 +79,8 @@ const satisfactionChartRef = ref<HTMLCanvasElement | null>(null)
const hitColumns = [ const hitColumns = [
{ colKey: 'idx', title: '排名', width: 50, cell: (_h: any, { rowIndex }: any) => rowIndex + 1 }, { colKey: 'idx', title: '排名', width: 50, cell: (_h: any, { rowIndex }: any) => rowIndex + 1 },
{ colKey: 'document_title', title: '文档标题', ellipsis: true }, { colKey: 'document_title', title: '文档标题', ellipsis: true },
{ colKey: 'hit_count', title: '命中次数', width: 80, cell: (_h: any, { row }: any) => row.hit_count ?? row.cnt ?? 0 },
{ colKey: 'avg_score', title: '平均得分', width: 80, cell: (_h: any, { row }: any) => row.avg_score != null ? Number(row.avg_score).toFixed(3) : '-' },
{ colKey: 'hit_count', title: '命中次数', width: 80, cell: (_h: any, { row }: any) => row.hit_count ?? row.cnt ?? 0, sorter: (a: any, b: any) => (a.hit_count ?? 0) - (b.hit_count ?? 0) },
{ colKey: 'avg_score', title: '平均得分', width: 80, cell: (_h: any, { row }: any) => row.avg_score != null ? Number(row.avg_score).toFixed(3) : '-', sorter: (a: any, b: any) => (a.avg_score ?? 0) - (b.avg_score ?? 0) },
] ]
const satisfactionColor = computed(() => { const satisfactionColor = computed(() => {

495
frontend/src/views/DocList.vue

@ -1,13 +1,53 @@
<template> <template>
<t-card title="文档列表" :bordered="false">
<t-card class="doc-list-card" title="文档列表" :bordered="false">
<div class="doc-layout">
<!-- 左侧:目录树面板 -->
<aside class="doc-tree-panel" :style="{ width: treeWidth + 'px' }">
<div class="tree-panel-header">
<span class="tree-panel-title">📁 文档目录</span>
<t-button variant="text" size="small" @click="loadTreeData">刷新</t-button>
</div>
<div class="tree-panel-body">
<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" :title="node.data.label">{{ node.data.label }}</span>
<t-dropdown class="tree-ops" trigger="click" :options="nodeActionOptions(node)" @click="(item: any) => onNodeAction(item, node)">
<t-button size="small" variant="text" shape="square" @click.stop>
<template #icon><MoreIcon /></template>
</t-button>
</t-dropdown>
</span>
</template>
</t-tree>
</div>
</aside>
<!-- 可拖拽分隔条 -->
<div class="tree-resizer" :class="{ dragging: isResizing }" @mousedown="startResize"></div>
<!-- 右侧:工具栏 + 表格 -->
<div class="doc-content">
<!-- 工具栏:搜索 + 筛选 + 批量操作 --> <!-- 工具栏:搜索 + 筛选 + 批量操作 -->
<div class="toolbar"> <div class="toolbar">
<t-input v-model="keyword" placeholder="搜索文档标题或文件名..." clearable @change="debouncedLoad" style="max-width:260px;" size="small" /> <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="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="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-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-button variant="outline" size="small" @click="load()">刷新</t-button>
<t-tag v-if="hasProcessing" theme="warning" size="small">⏳ 有文档处理中...</t-tag> <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>
<template v-if="selectedIds.size > 0"> <template v-if="selectedIds.size > 0">
<t-divider layout="vertical" /> <t-divider layout="vertical" />
@ -16,19 +56,22 @@
<t-button variant="outline" size="small" @click="batchReprocess">批量重新处理</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="batchEnable">批量启用</t-button>
<t-button variant="outline" size="small" @click="batchDisable">批量禁用</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="outline" size="small" @click="openMove(Array.from(selectedIds))">移动</t-button>
<t-button variant="text" size="small" @click="clearSelection">取消选择</t-button> <t-button variant="text" size="small" @click="clearSelection">取消选择</t-button>
</template> </template>
</div> </div>
<!-- 表格 -->
<t-table
<!-- 表格:外层容器撑满剩余高度,表格在其中内部滚动(表头固定) -->
<div class="doc-table-wrap">
<BaseTable
:max-height="'calc(100vh - 220px)'"
:data="documents" :columns="columns" row-key="id" :loading="loading" :data="documents" :columns="columns" row-key="id" :loading="loading"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" :pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }"
:selected-row-keys="Array.from(selectedIds)" :selected-row-keys="Array.from(selectedIds)"
:sort="sortInfo"
@page-change="onPageChange" @page-change="onPageChange"
@select-change="onSelectChange" @select-change="onSelectChange"
@sort-change="onSortChange"
> >
<template #title="{ row }"> <template #title="{ row }">
<div><strong>{{ row.title }}</strong></div> <div><strong>{{ row.title }}</strong></div>
@ -39,7 +82,9 @@
</template> </template>
<template #fileType="{ row }"><t-tag size="small" variant="light">{{ row.fileType }}</t-tag></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 #fileSize="{ row }"><span style="white-space:nowrap;">{{ formatSize(row.fileSize) }}</span></template>
<template #categoryId="{ row }"><span>{{ categoryStore.getCategoryName(row.categoryId) }}</span></template>
<template #categoryId="{ row }">
<span class="doc-pos" :title="docPath(row)">{{ docPath(row) }}</span>
</template>
<template #status="{ row }"> <template #status="{ row }">
<t-tag size="small" :theme="row.status === 'READY' ? 'success' : row.status === 'PROCESSING' ? 'warning' : 'danger'" <t-tag size="small" :theme="row.status === 'READY' ? 'success' : row.status === 'PROCESSING' ? 'warning' : 'danger'"
variant="light">{{ statusLabel(row.status) }}</t-tag> variant="light">{{ statusLabel(row.status) }}</t-tag>
@ -51,24 +96,65 @@
<t-space :size="2"> <t-space :size="2">
<t-button size="small" variant="text" @click="viewDetail(row.id)">查看</t-button> <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 v-show="row.filePath" size="small" variant="text" @click="download(row.id)">下载</t-button>
<t-button size="small" variant="text" @click="openMove([row.id])">移动</t-button>
<t-button size="small" variant="text" @click="reprocess(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-button size="small" variant="text" theme="danger" @click="remove(row.id)">删除</t-button>
</t-space> </t-space>
</template> </template>
</t-table>
</BaseTable>
</div>
</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-dialog v-model:visible="moveDialog.visible" header="移动文档" width="440px" :footer="false">
<div class="move-tree-tip">选择目标位置(分类根或目录):</div>
<t-tree
v-if="moveDialog.visible"
:data="moveTreeData"
:keys="{ value: 'value', label: 'label', children: 'children' }"
:activable="true"
:active-multiple="false"
hover
expand-all
v-model:actived="moveActiveKeys"
@active="onMoveTreeChange"
style="max-height:360px;overflow:auto;"
/>
<div class="dialog-footer">
<t-button variant="outline" @click="moveDialog.visible = false">取消</t-button>
<t-button theme="primary" :disabled="!moveDialog.target" @click="confirmMove">移动</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'
import { useDebounce } from '@/composables/useDebounce' import { useDebounce } from '@/composables/useDebounce'
import { MoreIcon } from 'tdesign-icons-vue-next'
const { confirm } = useConfirm() const { confirm } = useConfirm()
const { debounce } = useDebounce() const { debounce } = useDebounce()
@ -88,9 +174,39 @@ const filterStatus = ref('')
const keyword = ref('') const keyword = ref('')
const filterTag = ref('') const filterTag = ref('')
const selectedIds = ref(new Set<string>()) const selectedIds = ref(new Set<string>())
const moveCategoryId = ref('')
const hasProcessing = ref(false) const hasProcessing = ref(false)
// 移动文档弹窗状态
const moveDialog = ref({ visible: false, ids: [] as string[], target: null as any })
const moveActiveKeys = ref<string[]>([])
// 目录树相关状态
const treeData = ref<any[]>([])
const activedKeys = ref<string[]>([])
const filterFolder = ref('')
const folderTreeLoading = ref(false)
// 目录树宽度(可拖动,不持久化)
const treeWidth = ref(240)
const isResizing = ref(false)
const MIN_TREE_WIDTH = 200
const MAX_TREE_WIDTH = 480
let resizeCleanup: (() => void) | null = null
// 目录 id → { name, parentId },位置列据此沿 parentId 回溯拼目录路径
const folderMap = ref<Record<string, { name: string; parentId: string | number }>>({})
// 表格排序状态(服务端排序)
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
@ -112,27 +228,32 @@ function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null } if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
} }
onUnmounted(() => { stopPolling() })
onUnmounted(() => { stopPolling(); if (resizeCleanup) resizeCleanup() })
// ==================== 表格列 ==================== // ==================== 表格列 ====================
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: 'categoryId', title: '分类', width: 100 },
{ 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: 200 },
{ 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: 'op', title: '操作', width: 200 },
{ 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: 240 },
] ]
// ==================== 选项 ==================== // ==================== 选项 ====================
const categoryOptions = computed(() => [{ label: '全部分类', value: '' }, ...categoryStore.categories.map(c => ({ label: c.name, value: String(c.id) }))]) const categoryOptions = computed(() => [{ label: '全部分类', value: '' }, ...categoryStore.categories.map(c => ({ label: c.name, value: String(c.id) }))])
const statusOptions = [{ label: '全部状态', value: '' }, { label: '已完成', value: 'READY' }, { label: '处理中', value: 'PROCESSING' }, { label: '失败', value: 'FAILED' }] const statusOptions = [{ label: '全部状态', value: '' }, { label: '已完成', value: 'READY' }, { label: '处理中', value: 'PROCESSING' }, { label: '失败', value: 'FAILED' }]
const tagOptions = computed(() => [{ label: '全部标签', value: '' }, ...(documentStore.tags || []).map((t: any) => ({ label: `${t.tag} (${t.count})`, value: t.tag }))]) const tagOptions = computed(() => [{ label: '全部标签', value: '' }, ...(documentStore.tags || []).map((t: any) => ({ label: `${t.tag} (${t.count})`, value: t.tag }))])
const moveCategoryOptions = computed(() => [{ label: '移动到分类...', value: '' }, ...categoryStore.categories.map(c => ({ label: c.name, value: String(c.id) })), { label: '未分类', value: '0' }])
// 移动弹窗目录树:确保有「未分类」节点,保留移出到未分类的能力
const moveTreeData = computed(() => {
const hasUnclassified = treeData.value.some(n => n.value === 'cat:0')
if (hasUnclassified) return treeData.value
return [{ value: 'cat:0', label: '未分类', nodeType: 'category', categoryId: '0', children: [] }, ...treeData.value]
})
// ==================== 工具函数 ==================== // ==================== 工具函数 ====================
function getDocTags(doc: any): string[] { function getDocTags(doc: any): string[] {
@ -145,15 +266,260 @@ function getDocTags(doc: any): string[] {
function statusLabel(s: string) { return s === 'READY' ? '已完成' : s === 'PROCESSING' ? '处理中' : s === 'FAILED' ? '失败' : s } function statusLabel(s: string) { return s === 'READY' ? '已完成' : s === 'PROCESSING' ? '处理中' : s === 'FAILED' ? '失败' : s }
function formatSize(b: number) { return b ? formatBytes(b) : '-' } function formatSize(b: number) { return b ? formatBytes(b) : '-' }
/** 计算文档所在目录路径(不含分类名);直接挂分类根/无目录时返回「—」 */
function docPath(row: any): string {
let fid = row.folderId != null ? String(row.folderId) : '0'
const chain: string[] = []
let guard = 0
while (fid && fid !== '0' && guard++ < 20) {
const f = folderMap.value[fid]
if (!f) break
chain.unshift(f.name)
fid = String(f.parentId ?? '0')
}
return chain.length ? chain.join(' / ') : '—'
}
// ==================== 搜索防抖 ==================== // ==================== 搜索防抖 ====================
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)
// 目录映射:位置列只回溯目录链,无需分类链映射
folderMap.value = {}
for (const f of folders || []) folderMap.value[String(f.id)] = { name: f.name, parentId: f.parentId }
} 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 startResize(e: MouseEvent) {
e.preventDefault()
const startX = e.clientX
const startWidth = treeWidth.value
isResizing.value = true
const onMove = (ev: MouseEvent) => {
treeWidth.value = Math.min(MAX_TREE_WIDTH, Math.max(MIN_TREE_WIDTH, startWidth + (ev.clientX - startX)))
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
document.body.style.userSelect = ''
document.body.style.cursor = ''
isResizing.value = false
resizeCleanup = null
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
document.body.style.userSelect = 'none'
document.body.style.cursor = 'col-resize'
resizeCleanup = onUp
}
/** 表格排序变化:更新排序状态并重新加载(服务端排序) */
function onSortChange(sort: any) {
sortInfo.value = sort && sort.sortBy ? { sortBy: sort.sortBy, descending: !!sort.descending } : null
load(1)
}
// ==================== 目录操作 ====================
/** 目录/分类节点操作菜单项 */
function nodeActionOptions(node: any): any[] {
if (node.data.nodeType === 'folder') {
return [
{ content: '新建子目录', value: 'create' },
{ content: '重命名', value: 'rename' },
{ content: '删除', value: 'remove', theme: 'error', divider: true },
]
}
return [{ content: '新建目录', value: 'create' }]
}
/** 目录/分类节点操作分发 */
function onNodeAction(item: any, node: any) {
const d = node.data
if (item.value === 'create') {
openCreateFolder(d.categoryId, d.nodeType === 'folder' ? d.folderId : undefined)
} else if (item.value === 'rename') {
openRenameFolder(d)
} else if (item.value === 'remove') {
removeFolder(d)
}
}
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
@ -241,24 +607,91 @@ async function batchDisable() {
} catch (e: any) { toast('批量禁用失败:' + e.message, 'error') } } catch (e: any) { toast('批量禁用失败:' + e.message, 'error') }
} }
async function batchMove() {
const ids = Array.from(selectedIds.value)
const catId = moveCategoryId.value
if (!catId && catId !== '0') { toast('请选择目标分类', 'error'); return }
const catName = catId === '0' ? '未分类' : categoryStore.getCategoryName(catId)
if (!await confirm(`确定将选中的 ${ids.length} 个文档移动到「${catName}」?`)) return
function openMove(ids: string[]) {
moveDialog.value = { visible: true, ids, target: null }
moveActiveKeys.value = []
}
function onMoveTreeChange(_value: any[], ctx: any) {
moveDialog.value.target = ctx?.node?.data ?? null
}
async function confirmMove() {
const t = moveDialog.value.target
if (!t) { toast('请选择目标位置', 'error'); return }
// 目标为目录 → folderId=目录ID、categoryId=目录所属分类;目标为分类根 → folderId=0、categoryId=分类
const folderId = t.nodeType === 'folder' ? String(t.folderId ?? '0') : '0'
const categoryId = String(t.categoryId ?? '0')
const ids = moveDialog.value.ids
try { try {
// 雪花 ID 精度:0 保持为字符串 0,其余保持字符串
const json = await batchMoveDocuments(ids, catId)
if (json.success) { toast(json.message, 'success'); selectedIds.value = new Set(); moveCategoryId.value = ''; loadData(); documentStore.loadStats() }
else toast(json.message || '批量移动失败', 'error')
} catch (e: any) { toast('批量移动失败:' + e.message, 'error') }
const json = await batchMoveDocuments(ids, categoryId, folderId)
if (json.success) {
toast(json.message, 'success')
moveDialog.value.visible = false
moveActiveKeys.value = []
selectedIds.value = new Set()
loadData()
documentStore.loadStats()
} else {
toast(json.message || '移动失败', 'error')
}
} catch (e: any) {
toast('移动失败:' + e.message, 'error')
}
} }
// 初始化 // 初始化
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); }
/* 位置列:长路径省略号截断,悬停 title 看全 */
.doc-pos { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
/* 左右分栏布局 */
/* 卡片锁定一屏高度:左侧目录树独立滚动,右侧工具栏固定 + 表格内部滚动 */
.doc-list-card { height: 100%; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
.doc-list-card :deep(.t-card__header) { flex-shrink: 0; }
/* 关键修复:TDesign 在 header 与 body 之间有一层 .t-loading__parent,须让它参与 flex 高度链,
否则 .t-card__body 的 flex:1 不生效,目录树无法在面板内滚动而撑高整页 */
.doc-list-card :deep(.t-loading__parent) { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.doc-list-card :deep(.t-card__body) { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
.doc-layout { flex: 1; min-height: 0; display: flex; align-items: stretch; }
.doc-tree-panel {
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding-right: 12px;
border-right: 1px solid var(--color-border);
}
.tree-resizer {
flex-shrink: 0;
width: 6px;
margin-left: -6px;
cursor: col-resize;
background: transparent;
transition: background .2s;
}
.tree-resizer:hover, .tree-resizer.dragging { background: var(--color-primary); }
.tree-panel-body { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; }
.doc-content { flex: 1; min-width: 0; overflow: hidden; padding-left: 16px; display: flex; flex-direction: column; }
/* 表格宿主:撑满工具栏下方剩余高度,表格 max-height:100% 相对它解析,实现内部滚动与固定表头 */
.doc-table-wrap { flex: 1; min-height: 0; overflow: hidden; }
.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; width: 100%; min-width: 0; }
.tree-label-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-ops { display: inline-flex; align-items: center; flex-shrink: 0; margin-left: 4px; visibility: hidden; }
.tree-node:hover .tree-ops { visibility: visible; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
.move-tree-tip { font-size: 12px; color: var(--td-text-color-placeholder); margin-bottom: 8px; }
</style> </style>

192
frontend/src/views/DocUpload.vue

@ -5,6 +5,18 @@
<!-- 上传元信息 --> <!-- 上传元信息 -->
<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' }"
:disabled="!uploadCategory"
clearable
filterable
placeholder="请先选择分类"
size="small"
style="width:240px;"
@change="onFolderChange"
/>
<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 +27,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 +52,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch, h } from 'vue'
import { DialogPlugin } from 'tdesign-vue-next'
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 { getFolderTree } from '@/api/folder'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
const categoryStore = useCategoryStore() const categoryStore = useCategoryStore()
@ -49,6 +68,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 +101,23 @@ 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.startsWith('folder:') ? value.slice('folder:'.length) : ''
}
/**
* 组装表单数据
* @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) {
const fid = stripFolderPrefix(uploadFolderId.value)
if (fid) fd.append('folderId', fid)
}
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 +139,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 +186,152 @@ 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 用) ====================
// 分类 = 盘符(根),目录 = 盘符下的文件夹;有分类才可能有目录,故目录树只含目录、不含分类节点
/** 目录节点 → tree-select 选项 */
function folderNodeToOption(n: any): any {
return {
value: 'folder:' + n.id,
label: n.name,
categoryId: n.categoryId,
children: (n.children || []).map(folderNodeToOption),
}
}
/** 加载当前分类下的目录树;未选分类时目录选择器为空/禁用 */
async function loadFolderTree() {
const catId = uploadCategory.value
if (!catId) { folderTreeData.value = []; return }
try {
const res = await getFolderTree(catId)
folderTreeData.value = res.success ? (res.data || []).map(folderNodeToOption) : []
} catch (e: any) {
toast('加载目录失败:' + e.message, 'error')
}
}
/** 分类变化 → 清空目录并刷新目录树(分类是目录的前提) */
watch(uploadCategory, () => {
uploadFolderId.value = ''
loadFolderTree()
})
/** 目录选中 → 回填分类,保持两者一致 */
function onFolderChange(_value: any, ctx: any) {
if (ctx?.trigger === 'check' && ctx?.data?.categoryId != null) {
uploadCategory.value = String(ctx.data.categoryId)
}
}
// ==================== 文件夹上传 ====================
function pickFolder() {
folderInput.value?.click()
}
/** 将 details 按失败原因分组,返回 [{ error, files: string[] }] */
function groupFailures(details: Array<{ file?: string; error?: string }> = []) {
const map = new Map<string, string[]>()
for (const d of details) {
const err = d?.error || '未知错误'
const file = d?.file || '未知文件'
if (!map.has(err)) map.set(err, [])
map.get(err)!.push(file)
}
return Array.from(map.entries()).map(([error, files]) => ({ error, files }))
}
/** 弹窗展示文件夹上传结果(成功/失败数 + 按原因分组的失败文件清单) */
function showFolderUploadResult(successCount: number, failCount: number, details: any[]) {
const groups = groupFailures(details)
const body = h('div', { style: 'max-height:320px;overflow-y:auto;' }, [
h('p', { style: 'margin:0 0 12px;' }, `成功 ${successCount} 个,失败 ${failCount} 个`),
...groups.map((g) => h('div', { style: 'margin-bottom:12px;' }, [
h('div', { style: 'color:var(--td-error-color);font-weight:600;margin-bottom:4px;' }, g.error),
h('ul', { style: 'margin:0;padding-left:18px;color:var(--td-text-color-secondary);font-size:12px;' },
g.files.slice(0, 10).map((f) => h('li', {}, f))),
g.files.length > 10
? h('div', { style: 'color:var(--td-text-color-placeholder);font-size:12px;' }, `…等 ${g.files.length} 个文件`)
: null,
])),
])
const dlg = DialogPlugin.alert({
header: '文件夹上传结果',
body,
confirmBtn: '知道了',
// 确认按钮默认只触发 onConfirm、不自动关闭,需手动 hide
onConfirm: () => { dlg.hide() },
})
}
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 }
// 构造 FormData:files + relativePaths(保留完整相对路径,含所选文件夹根名,后端据此建目录树)
const fd = new FormData()
valid.forEach(f => fd.append('files', f))
const relativePaths: string[] = valid.map(f => (f as any).webkitRelativePath || f.name)
relativePaths.forEach(p => fd.append('relativePaths', p))
if (uploadCategory.value) fd.append('categoryId', uploadCategory.value)
const fid = stripFolderPrefix(uploadFolderId.value)
if (fid) fd.append('folderId', fid)
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
const details = r.data?.details ?? []
if (failCount > 0) {
toast(`文件夹上传完成:成功 ${successCount ?? '?'} 个,失败 ${failCount} 个`, 'warning')
showFolderUploadResult(successCount ?? 0, failCount, details)
} 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 = ''
}
}
</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>

18
frontend/src/views/FaqManager.vue

@ -19,8 +19,8 @@
</div> </div>
<!-- FAQ 表格 --> <!-- FAQ 表格 -->
<t-table :data="faqs" :columns="columns" row-key="id" :loading="loading"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange">
<BaseTable :data="faqs" :columns="columns" row-key="id" :loading="loading" :sort="sortInfo"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange" @sort-change="onSortChange">
<template #status="{ row }"> <template #status="{ row }">
<span @click="toggleStatus(row)" style="cursor:pointer;" :style="{color: row.status === 'ENABLED' ? 'var(--td-success-color)' : 'var(--td-error-color)'}"> <span @click="toggleStatus(row)" style="cursor:pointer;" :style="{color: row.status === 'ENABLED' ? 'var(--td-success-color)' : 'var(--td-error-color)'}">
{{ row.status === 'ENABLED' ? '✅ 启用' : '❌ 禁用' }} {{ row.status === 'ENABLED' ? '✅ 启用' : '❌ 禁用' }}
@ -32,7 +32,7 @@
<t-button size="small" variant="text" theme="danger" @click="removeFaq(row.id)">删除</t-button> <t-button size="small" variant="text" theme="danger" @click="removeFaq(row.id)">删除</t-button>
</t-space> </t-space>
</template> </template>
</t-table>
</BaseTable>
<!-- 新增/编辑抽屉(富文本 Markdown 编辑器需宽幅,用抽屉替代窄弹窗) --> <!-- 新增/编辑抽屉(富文本 Markdown 编辑器需宽幅,用抽屉替代窄弹窗) -->
<FormDrawer <FormDrawer
@ -133,15 +133,16 @@ async function handleUploadImg(files: File[], callback: (urls: string[]) => void
} }
const columns = [ const columns = [
{ colKey: 'question', title: '问题', width: 200, ellipsis: true },
{ colKey: 'question', title: '问题', width: 200, ellipsis: true, sorter: true },
{ colKey: 'answer', title: '答案(摘要)', width: 200, ellipsis: true, cell: (_h: any, { row }: any) => stripMarkdown(row.answer || '') }, { colKey: 'answer', title: '答案(摘要)', width: 200, ellipsis: true, cell: (_h: any, { row }: any) => stripMarkdown(row.answer || '') },
{ colKey: 'categoryName', title: '分类', width: 100, cell: (_h:any,{row}:any)=>categoryMap.value[row.categoryId]||'-' }, { colKey: 'categoryName', title: '分类', width: 100, cell: (_h:any,{row}:any)=>categoryMap.value[row.categoryId]||'-' },
{ colKey: 'source', title: '来源', width: 70, cell: (_h:any,{row}:any)=>sourceLabel(row.source) }, { colKey: 'source', title: '来源', width: 70, cell: (_h:any,{row}:any)=>sourceLabel(row.source) },
{ colKey: 'priority', title: '优先级', width: 60 },
{ colKey: 'hitCount', title: '命中', width: 55 },
{ colKey: 'status', title: '状态', width: 75 },
{ colKey: 'priority', title: '优先级', width: 60, sorter: true },
{ colKey: 'hitCount', title: '命中', width: 55, sorter: true },
{ colKey: 'status', title: '状态', width: 75, sorter: true },
{ colKey: 'op', title: '操作', width: 120 }, { colKey: 'op', title: '操作', width: 120 },
] ]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
const statusOptions = [{label:'全部状态',value:''},{label:'启用',value:'ENABLED'},{label:'禁用',value:'DISABLED'}] const statusOptions = [{label:'全部状态',value:''},{label:'启用',value:'ENABLED'},{label:'禁用',value:'DISABLED'}]
const categoryOptions = computed(()=>[{label:'全部分类',value:''},...flatCategories.value.map(c=>({label:c.path,value:String(c.id)}))]) const categoryOptions = computed(()=>[{label:'全部分类',value:''},...flatCategories.value.map(c=>({label:c.path,value:String(c.id)}))])
@ -159,13 +160,14 @@ function flattenTree(tree: any[], prefix = ''): any[] {
} }
async function loadList() { loading.value = true async function loadList() { loading.value = true
try { const r = await listFaqs(page.value,pageSize.value,searchKeyword.value||undefined,filterCategoryId.value||undefined,filterStatus.value||undefined)
try { const r = await listFaqs(page.value,pageSize.value,searchKeyword.value||undefined,filterCategoryId.value||undefined,filterStatus.value||undefined,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined)
if (r.success) { faqs.value = r.data?.records||r.data||[]; total.value = r.data?.total||r.total||0 } } catch(e:any){ toast('加载失败:'+e.message,'error') } if (r.success) { faqs.value = r.data?.records||r.data||[]; total.value = r.data?.total||r.total||0 } } catch(e:any){ toast('加载失败:'+e.message,'error') }
finally { loading.value = false } } finally { loading.value = false } }
async function loadStats() { try { const r = await getFaqStats(); if(r.success) stats.value=r.data } catch {/* */} } async function loadStats() { try { const r = await getFaqStats(); if(r.success) stats.value=r.data } catch {/* */} }
function onPageChange(info:{current:number;pageSize:number}) { page.value=info.current; pageSize.value=info.pageSize; loadList() } function onPageChange(info:{current:number;pageSize:number}) { page.value=info.current; pageSize.value=info.pageSize; loadList() }
function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;loadList()}
const debouncedSearch = debounce(() => { page.value = 1; loadList() }) const debouncedSearch = debounce(() => { page.value = 1; loadList() })
/** 表单当前值快照,用于判断是否有未保存修改 */ /** 表单当前值快照,用于判断是否有未保存修改 */

18
frontend/src/views/FeedbackOps.vue

@ -11,8 +11,8 @@
</div> </div>
<!-- 反馈列表 --> <!-- 反馈列表 -->
<t-table :data="feedbacks" :columns="columns" row-key="conversationId" :loading="loading"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange"
<BaseTable :data="feedbacks" :columns="columns" row-key="conversationId" :loading="loading" :sort="sortInfo"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange" @sort-change="onSortChange"
empty="暂无反馈数据,用户在 SDK 端对 AI 回复点赞或点踩后将在此展示。"> empty="暂无反馈数据,用户在 SDK 端对 AI 回复点赞或点踩后将在此展示。">
<template #feedbackType="{ row }"> <template #feedbackType="{ row }">
<t-tag :theme="row.feedbackType === 'THUMBS_UP' ? 'success' : 'danger'" variant="light"> <t-tag :theme="row.feedbackType === 'THUMBS_UP' ? 'success' : 'danger'" variant="light">
@ -45,7 +45,7 @@
<t-button size="small" variant="text" theme="danger" @click="removeFeedback(row)">删除</t-button> <t-button size="small" variant="text" theme="danger" @click="removeFeedback(row)">删除</t-button>
</t-space> </t-space>
</template> </template>
</t-table>
</BaseTable>
<!-- 消息详情弹窗 --> <!-- 消息详情弹窗 -->
<t-dialog v-model:visible="detailModal.visible" header="会话消息详情" width="800px" :footer="false"> <t-dialog v-model:visible="detailModal.visible" header="会话消息详情" width="800px" :footer="false">
@ -133,15 +133,16 @@ const processedOptions = [
] ]
const columns = [ const columns = [
{ colKey: 'feedbackType', title: '反馈', width: 90, cell: 'feedbackType' },
{ colKey: 'reasonCategory', title: '原因', width: 80, cell: 'reasonCategory' },
{ colKey: 'feedbackType', title: '反馈', width: 90, cell: 'feedbackType', sorter: true },
{ colKey: 'reasonCategory', title: '原因', width: 80, cell: 'reasonCategory', sorter: true },
{ colKey: 'reasonComment', title: '补充说明', width: 160, ellipsis: true, cell: 'reasonComment' }, { colKey: 'reasonComment', title: '补充说明', width: 160, ellipsis: true, cell: 'reasonComment' },
{ colKey: 'userQuestion', title: '用户问题', width: 200, ellipsis: true, cell: 'userQuestion' }, { colKey: 'userQuestion', title: '用户问题', width: 200, ellipsis: true, cell: 'userQuestion' },
{ colKey: 'assistantAnswer', title: 'AI 回复', width: 200, ellipsis: true, cell: 'assistantAnswer' }, { colKey: 'assistantAnswer', title: 'AI 回复', width: 200, ellipsis: true, cell: 'assistantAnswer' },
{ colKey: 'processed', title: '状态', width: 70, cell: 'processed' },
{ colKey: 'feedbackTime', title: '时间', width: 150, cell: (_: any, { row }: any) => formatDate(row.feedbackTime) },
{ colKey: 'processed', title: '状态', width: 70, cell: 'processed', sorter: true },
{ colKey: 'feedbackTime', title: '时间', width: 150, cell: (_: any, { row }: any) => formatDate(row.feedbackTime), sorter: true },
{ colKey: 'op', title: '操作', width: 240, cell: 'op' }, { colKey: 'op', title: '操作', width: 240, cell: 'op' },
] ]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
const canSubmit = computed(() => { const canSubmit = computed(() => {
const m = faqModal.value const m = faqModal.value
@ -201,6 +202,8 @@ async function loadList(p: number) {
page.value = p; loading.value = true page.value = p; loading.value = true
try { try {
const params: Record<string, any> = { page: p, size: pageSize.value } const params: Record<string, any> = { page: p, size: pageSize.value }
if (sortInfo.value?.sortBy) params.sortField = sortInfo.value.sortBy
if (sortInfo.value) params.sortOrder = sortInfo.value.descending ? 'desc' : 'asc'
if (filterFeedbackType.value) params.feedbackType = filterFeedbackType.value if (filterFeedbackType.value) params.feedbackType = filterFeedbackType.value
if (filterReasonCategory.value) params.reasonCategory = filterReasonCategory.value if (filterReasonCategory.value) params.reasonCategory = filterReasonCategory.value
if (filterProcessed.value) params.processed = filterProcessed.value === 'true' if (filterProcessed.value) params.processed = filterProcessed.value === 'true'
@ -222,6 +225,7 @@ async function loadList(p: number) {
} }
function onPageChange(i: { current: number; pageSize: number }) { pageSize.value = i.pageSize; loadList(i.current) } function onPageChange(i: { current: number; pageSize: number }) { pageSize.value = i.pageSize; loadList(i.current) }
function onSortChange(s: any) { sortInfo.value = s && s.sortBy ? { sortBy: s.sortBy, descending: !!s.descending } : null; loadList(1) }
async function viewDetail(row: any) { async function viewDetail(row: any) {
try { try {

25
frontend/src/views/McpServerManager.vue

@ -9,11 +9,11 @@
<span class="total-hint">共 {{ total }} 条配置</span> <span class="total-hint">共 {{ total }} 条配置</span>
</div> </div>
<t-table :data="servers" :columns="columns" row-key="id"
:pagination="{ current: currentPage, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange">
<BaseTable :data="servers" :columns="columns" row-key="id" :loading="loading" :sort="sortInfo"
:pagination="{ current: currentPage, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange" @sort-change="onSortChange">
<template #transport_type="{ row }"><t-tag size="small" :theme="row.transport_type==='sse'?'primary':'default'" variant="light">{{ row.transport_type==='sse'?'SSE':'Stdio' }}</t-tag></template> <template #transport_type="{ row }"><t-tag size="small" :theme="row.transport_type==='sse'?'primary':'default'" variant="light">{{ row.transport_type==='sse'?'SSE':'Stdio' }}</t-tag></template>
<template #displayUrl="{ row }"><span class="ellipsis" :title="getDisplayUrl(row)">{{ getDisplayUrl(row)||'-' }}</span></template> <template #displayUrl="{ row }"><span class="ellipsis" :title="getDisplayUrl(row)">{{ getDisplayUrl(row)||'-' }}</span></template>
<template #is_active="{ row }"><t-switch v-model="row._active" size="small" @change="toggleActive(row)" /></template>
<template #is_active="{ row }"><t-switch v-model="row._active" size="small" :disabled="!!row._toggling" @change="toggleActive(row)" /></template>
<template #op="{ row }"> <template #op="{ row }">
<t-space :size="4"> <t-space :size="4">
<t-button size="small" variant="text" @click="testConnection(row)" :disabled="testLoading[row.id]">{{ testLoading[row.id]?'测试中...':'测试' }}</t-button> <t-button size="small" variant="text" @click="testConnection(row)" :disabled="testLoading[row.id]">{{ testLoading[row.id]?'测试中...':'测试' }}</t-button>
@ -21,7 +21,7 @@
<t-button size="small" variant="text" theme="danger" @click="remove(row.id,row.name)">删除</t-button> <t-button size="small" variant="text" theme="danger" @click="remove(row.id,row.name)">删除</t-button>
</t-space> </t-space>
</template> </template>
</t-table>
</BaseTable>
<!-- 测试结果 --> <!-- 测试结果 -->
<div v-if="testResult.visible" class="test-toast" :style="{background:testResult.success?'var(--color-success-text)':'var(--color-error-text)'}"> <div v-if="testResult.visible" class="test-toast" :style="{background:testResult.success?'var(--color-success-text)':'var(--color-error-text)'}">
@ -68,20 +68,22 @@ import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm() const { confirm } = useConfirm()
const servers = ref<any[]>([]); const currentPage=ref(1); const pageSize=ref(10); const total=ref(0)
const servers = ref<any[]>([]); const currentPage=ref(1); const pageSize=ref(10); const total=ref(0); const loading=ref(false)
const showModal=ref(false); const modalMode=ref('add'); const editId=ref<any>(null); const saving=ref(false) const showModal=ref(false); const modalMode=ref('add'); const editId=ref<any>(null); const saving=ref(false)
const form=ref({name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',headersText:'',description:''}) const form=ref({name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',headersText:'',description:''})
const testLoading=ref<Record<string,boolean>>({}); const testResult=ref({visible:false,success:false,message:'' as any}); let testTimer:any=null const testLoading=ref<Record<string,boolean>>({}); const testResult=ref({visible:false,success:false,message:'' as any}); let testTimer:any=null
const refreshLoading=ref(false) const refreshLoading=ref(false)
const columns=[{colKey:'name',title:'名称',width:130},{colKey:'transport_type',title:'传输类型',width:90},{colKey:'displayUrl',title:'URL/命令',width:200,ellipsis:true},{colKey:'is_active',title:'状态',width:70},{colKey:'description',title:'描述',width:140,ellipsis:true},{colKey:'create_time',title:'创建时间',width:160,cell:(_:any,{row}:any)=>formatDate(row.create_time)},{colKey:'op',title:'操作',width:170,}]
const columns=[{colKey:'name',title:'名称',width:130,sorter:true},{colKey:'transport_type',title:'传输类型',width:90,sorter:true},{colKey:'displayUrl',title:'URL/命令',width:200,ellipsis:true},{colKey:'is_active',title:'状态',width:70,sorter:true},{colKey:'description',title:'描述',width:140,ellipsis:true,sorter:true},{colKey:'create_time',title:'创建时间',width:160,sorter:true,cell:(_:any,{row}:any)=>formatDate(row.create_time)},{colKey:'op',title:'操作',width:170,}]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
onMounted(()=>load()) onMounted(()=>load())
onUnmounted(()=>{if(testTimer)clearTimeout(testTimer)}) onUnmounted(()=>{if(testTimer)clearTimeout(testTimer)})
async function load(p=1){currentPage.value=p
try{const r=await api.listMcpServers(p,pageSize.value);if(r.success){servers.value=(r.data||[]).map((s:any)=>({...s,_active:s.is_active}));total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}}
async function load(p=1){currentPage.value=p;loading.value=true
try{const r=await api.listMcpServers(p,pageSize.value,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined);if(r.success){servers.value=(r.data||[]).map((s:any)=>({...s,_active:s.is_active}));total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
function onPageChange(i:{current:number;pageSize:number}){pageSize.value=i.pageSize;load(i.current)} function onPageChange(i:{current:number;pageSize:number}){pageSize.value=i.pageSize;load(i.current)}
function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;load(1)}
function createEmpty(){return{name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',headersText:'',description:''}} function createEmpty(){return{name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',headersText:'',description:''}}
function openAddModal(){modalMode.value='add';editId.value=null;form.value=createEmpty();showModal.value=true} function openAddModal(){modalMode.value='add';editId.value=null;form.value=createEmpty();showModal.value=true}
@ -106,8 +108,11 @@ async function save(){
async function remove(id:string,name:string){if(!await confirm('确定删除「'+(name||id)+'」?'))return async function remove(id:string,name:string){if(!await confirm('确定删除「'+(name||id)+'」?'))return
try{const r=await api.deleteMcpServer(id);if(r.success){toast('删除成功','success');load(currentPage.value)}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}} try{const r=await api.deleteMcpServer(id);if(r.success){toast('删除成功','success');load(currentPage.value)}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}}
async function toggleActive(s:any){const na=!s.is_active
try{const r=await api.toggleMcpServer(s.id,na);if(r.success){toast(na?'已启用':'已禁用','success');s.is_active=na;s._active=na}else toast(r.message||'操作失败','error')}catch(e:any){toast('操作失败:'+e.message,'error')}}
async function toggleActive(s:any){
if(s._toggling)return // 防连点:上一次请求未返回前忽略新切换
const na=!s.is_active
s._toggling=true
try{const r=await api.toggleMcpServer(s.id,na);if(r.success){toast(na?'已启用':'已禁用','success');s.is_active=na;s._active=na}else{s._active=s.is_active;toast(r.message||'操作失败','error')}}catch(e:any){s._active=s.is_active;toast('操作失败:'+e.message,'error')}finally{s._toggling=false}}
async function testConnection(s:any){testLoading.value={...testLoading.value,[s.id]:true} async function testConnection(s:any){testLoading.value={...testLoading.value,[s.id]:true}
try{const r=await api.testMcpServer(s.id);showResult(r)}catch(e:any){showResult({success:false,message:e.message})}finally{testLoading.value={...testLoading.value,[s.id]:false}}} try{const r=await api.testMcpServer(s.id);showResult(r)}catch(e:any){showResult({success:false,message:e.message})}finally{testLoading.value={...testLoading.value,[s.id]:false}}}

20
frontend/src/views/ModelConfigManager.vue

@ -67,10 +67,9 @@
</div> </div>
<!-- 配置列表表格 --> <!-- 配置列表表格 -->
<div style="overflow-x:auto;">
<t-table :data="configs" :columns="tableColumns" row-key="id" :loading="tableLoading" hover stripe
<BaseTable :data="configs" :columns="tableColumns" row-key="id" :loading="tableLoading" :sort="sortInfo"
:pagination="{ current: currentPage, total: total, pageSize: pageSize, showJumper: true }" :pagination="{ current: currentPage, total: total, pageSize: pageSize, showJumper: true }"
@page-change="onPageChange">
@page-change="onPageChange" @sort-change="onSortChange">
<template #app_type="{ row }"> <template #app_type="{ row }">
<t-tag size="small" variant="light" theme="primary">{{ getAppTypeLabel(row.app_type) }}</t-tag> <t-tag size="small" variant="light" theme="primary">{{ getAppTypeLabel(row.app_type) }}</t-tag>
</template> </template>
@ -104,8 +103,7 @@
<t-button v-show="!row.is_active" size="small" variant="text" theme="danger" @click="remove(row.id, row.name)" title="删除">🗑️</t-button> <t-button v-show="!row.is_active" size="small" variant="text" theme="danger" @click="remove(row.id, row.name)" title="删除">🗑️</t-button>
</t-space> </t-space>
</template> </template>
</t-table>
</div>
</BaseTable>
<!-- F1: 测试结果提示 --> <!-- F1: 测试结果提示 -->
<div v-if="testResult.visible" class="test-result-toast" <div v-if="testResult.visible" class="test-result-toast"
@ -355,14 +353,15 @@ const importConflictOptions = [
// ==================== 表格列定义 ==================== // ==================== 表格列定义 ====================
const tableColumns = [ const tableColumns = [
{ colKey: 'name', title: '配置名称', width: 160 },
{ colKey: 'app_type', title: '应用类型', width: 100 },
{ colKey: 'model_name', title: '模型名称', width: 160 },
{ colKey: 'name', title: '配置名称', width: 160, sorter: true },
{ colKey: 'app_type', title: '应用类型', width: 100, sorter: true },
{ colKey: 'model_name', title: '模型名称', width: 160, sorter: true },
{ colKey: 'base_url', title: 'API 地址', width: 180 }, { colKey: 'base_url', title: 'API 地址', width: 180 },
{ colKey: 'api_key', title: 'API Key', width: 180 }, { colKey: 'api_key', title: 'API Key', width: 180 },
{ colKey: 'status', title: '状态', width: 120 },
{ colKey: 'status', title: '状态', width: 120, sorter: true },
{ colKey: 'op', title: '操作', width: 240 }, { colKey: 'op', title: '操作', width: 240 },
] ]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
// ==================== 状态 ==================== // ==================== 状态 ====================
const configs = ref<any[]>([]) const configs = ref<any[]>([])
@ -514,7 +513,7 @@ async function load(p = currentPage.value) {
tableLoading.value = true tableLoading.value = true
currentPage.value = p currentPage.value = p
try { try {
const json = await api.listModelConfigs(p, pageSize.value, filterAppType.value || undefined)
const json = await api.listModelConfigs(p, pageSize.value, filterAppType.value || undefined, sortInfo.value?.sortBy || undefined, sortInfo.value ? (sortInfo.value.descending ? 'desc' : 'asc') : undefined)
if (json.success) { if (json.success) {
configs.value = json.data || [] configs.value = json.data || []
total.value = json.total || 0 total.value = json.total || 0
@ -525,6 +524,7 @@ async function load(p = currentPage.value) {
} }
function onPageChange(pageInfo: { current: number; pageSize: number }) { pageSize.value = pageInfo.pageSize; load(pageInfo.current) } function onPageChange(pageInfo: { current: number; pageSize: number }) { pageSize.value = pageInfo.pageSize; load(pageInfo.current) }
function onSortChange(s: any) { sortInfo.value = s && s.sortBy ? { sortBy: s.sortBy, descending: !!s.descending } : null; load(1) }
// ==================== 健康状态 ==================== // ==================== 健康状态 ====================
async function loadHealthStatus() { async function loadHealthStatus() {

52
frontend/src/views/PromptTracePanel.vue

@ -12,7 +12,7 @@
<div class="stat-card"><span class="stat-num">{{ statSummary.avgLatency }}</span><span class="stat-label">平均耗时(ms)</span></div> <div class="stat-card"><span class="stat-num">{{ statSummary.avgLatency }}</span><span class="stat-label">平均耗时(ms)</span></div>
<div class="stat-card"><span class="stat-num">{{ statSummary.faqRate }}</span><span class="stat-label">FAQ 命中率</span></div> <div class="stat-card"><span class="stat-num">{{ statSummary.faqRate }}</span><span class="stat-label">FAQ 命中率</span></div>
</div> </div>
<t-table :data="stats" :columns="statsColumns" row-key="key" size="small" :loading="statsLoading" :pagination="false" style="margin-bottom:16px;" />
<BaseTable :data="stats" :columns="statsColumns" row-key="key" :loading="statsLoading" :pagination="false" :max-height="''" style="margin-bottom:16px;" />
<!-- 筛选栏 --> <!-- 筛选栏 -->
<div class="toolbar"> <div class="toolbar">
@ -32,10 +32,10 @@
</div> </div>
<!-- 调用列表 --> <!-- 调用列表 -->
<t-table :data="traces" :columns="columns" row-key="id" :loading="loading"
<BaseTable :data="traces" :columns="columns" row-key="id" :loading="loading" :sort="sortInfo"
:selected-row-keys="selectedRowKeys" :selected-row-keys="selectedRowKeys"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" :pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }"
@page-change="onPageChange" @select-change="onSelectChange">
@page-change="onPageChange" @select-change="onSelectChange" @sort-change="onSortChange">
<template #roleName="{ row }"> <template #roleName="{ row }">
<span v-if="row.roleName">{{ row.roleName }}</span> <span v-if="row.roleName">{{ row.roleName }}</span>
<span v-else class="muted">(无角色)</span> <span v-else class="muted">(无角色)</span>
@ -59,7 +59,7 @@
<t-button size="small" variant="text" @click="openDetail(row)">详情</t-button> <t-button size="small" variant="text" @click="openDetail(row)">详情</t-button>
<t-button size="small" variant="text" @click="openTimeline(row)">时间线</t-button> <t-button size="small" variant="text" @click="openTimeline(row)">时间线</t-button>
</template> </template>
</t-table>
</BaseTable>
<!-- 详情抽屉 --> <!-- 详情抽屉 -->
<t-drawer v-model:visible="detailVisible" size="large" header="调用详情" :footer="false"> <t-drawer v-model:visible="detailVisible" size="large" header="调用详情" :footer="false">
@ -97,7 +97,7 @@
</t-tab-panel> </t-tab-panel>
<t-tab-panel value="rag" label="RAG 资料"> <t-tab-panel value="rag" label="RAG 资料">
<div class="muted" style="margin-bottom:8px;">检索模式:{{ detail.searchMode || '-' }} · 命中:{{ detail.hitCount ?? '-' }} 条</div> <div class="muted" style="margin-bottom:8px;">检索模式:{{ detail.searchMode || '-' }} · 命中:{{ detail.hitCount ?? '-' }} 条</div>
<t-table v-if="detailRagHits.length" :data="detailRagHits" :columns="ragHitsColumns" size="small" :pagination="false" style="margin-bottom:12px;" />
<BaseTable v-if="detailRagHits.length" :data="detailRagHits" :columns="ragHitsColumns" :pagination="false" :max-height="''" style="margin-bottom:12px;" />
<div class="block-title">注入的 RAG 上下文</div> <div class="block-title">注入的 RAG 上下文</div>
<pre class="full-prompt">{{ detail.ragContext || '(无 RAG 资料)' }}</pre> <pre class="full-prompt">{{ detail.ragContext || '(无 RAG 资料)' }}</pre>
</t-tab-panel> </t-tab-panel>
@ -111,18 +111,18 @@
</div> </div>
</t-tab-panel> </t-tab-panel>
<t-tab-panel value="tools" label="工具调用"> <t-tab-panel value="tools" label="工具调用">
<t-table v-if="detailToolCalls.length" :data="detailToolCalls" :columns="toolCallsColumns" size="small" :pagination="false">
<BaseTable v-if="detailToolCalls.length" :data="detailToolCalls" :columns="toolCallsColumns" :pagination="false" :max-height="''">
<template #latencyMs="{ row }"><span>{{ row.latencyMs ?? '-' }}ms</span></template> <template #latencyMs="{ row }"><span>{{ row.latencyMs ?? '-' }}ms</span></template>
<template #error="{ row }"> <template #error="{ row }">
<t-tag v-if="row.error" size="small" variant="light" theme="danger">失败</t-tag> <t-tag v-if="row.error" size="small" variant="light" theme="danger">失败</t-tag>
<span v-else class="muted">-</span> <span v-else class="muted">-</span>
</template> </template>
</t-table>
</BaseTable>
<t-empty v-else description="本次调用未触发 MCP 工具" /> <t-empty v-else description="本次调用未触发 MCP 工具" />
</t-tab-panel> </t-tab-panel>
<t-tab-panel value="context" label="对话上下文"> <t-tab-panel value="context" label="对话上下文">
<div v-if="detail.historyTurns" class="muted" style="margin-bottom:8px;">历史轮数:{{ detail.historyTurns }}</div> <div v-if="detail.historyTurns" class="muted" style="margin-bottom:8px;">历史轮数:{{ detail.historyTurns }}</div>
<t-table v-if="detailHistory.length" :data="detailHistory" :columns="historyColumns" size="small" :pagination="false" />
<BaseTable v-if="detailHistory.length" :data="detailHistory" :columns="historyColumns" :pagination="false" :max-height="''" />
<t-empty v-else description="无多轮历史记录" /> <t-empty v-else description="无多轮历史记录" />
</t-tab-panel> </t-tab-panel>
</t-tabs> </t-tabs>
@ -263,17 +263,18 @@ const selectedRowKeys = ref<string[]>([])
const columns = [ const columns = [
{ colKey: 'row-select', type: 'multiple', width: 40 }, { colKey: 'row-select', type: 'multiple', width: 40 },
{ colKey: 'createTime', title: '时间', width: 160, cell: (_: any, { row }: any) => formatDate(row.createTime) },
{ colKey: 'roleName', title: '角色', width: 120 },
{ colKey: 'intent', title: '意图', width: 100 },
{ colKey: 'enableRag', title: 'RAG', width: 70 },
{ colKey: 'modelName', title: '模型', width: 140, ellipsis: true },
{ colKey: 'latencyMs', title: '耗时', width: 90 },
{ colKey: 'faqHit', title: 'FAQ', width: 80 },
{ colKey: 'status', title: '状态', width: 100 },
{ colKey: 'createTime', title: '时间', width: 160, sorter: true, cell: (_: any, { row }: any) => formatDate(row.createTime) },
{ colKey: 'roleName', title: '角色', width: 120, sorter: true },
{ colKey: 'intent', title: '意图', width: 100, sorter: true },
{ colKey: 'enableRag', title: 'RAG', width: 70, sorter: true },
{ colKey: 'modelName', title: '模型', width: 140, ellipsis: true, sorter: true },
{ colKey: 'latencyMs', title: '耗时', width: 90, sorter: true },
{ colKey: 'faqHit', title: 'FAQ', width: 80, sorter: true },
{ colKey: 'status', title: '状态', width: 100, sorter: true },
{ colKey: 'userMessage', title: '用户消息', ellipsis: true }, { colKey: 'userMessage', title: '用户消息', ellipsis: true },
{ colKey: 'op', title: '操作', width: 150, fixed: 'right' }, { colKey: 'op', title: '操作', width: 150, fixed: 'right' },
] ]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
// ===== 聚合统计状态 ===== // ===== 聚合统计状态 =====
const groupBy = ref<'role' | 'model'>('role') const groupBy = ref<'role' | 'model'>('role')
@ -281,9 +282,9 @@ const stats = ref<any[]>([])
const statsLoading = ref(false) const statsLoading = ref(false)
const statsColumns = [ const statsColumns = [
{ colKey: 'key', title: '分组', ellipsis: true }, { colKey: 'key', title: '分组', ellipsis: true },
{ colKey: 'count', title: '调用数', width: 100 },
{ colKey: 'avgLatencyMs', title: '平均耗时(ms)', width: 120 },
{ colKey: 'faqHitRate', title: 'FAQ 命中率', width: 110, cell: (_: any, { row }: any) => `${((row.faqHitRate || 0) * 100).toFixed(1)}%` },
{ colKey: 'count', title: '调用数', width: 100, sorter: (a: any, b: any) => (a.count ?? 0) - (b.count ?? 0) },
{ colKey: 'avgLatencyMs', title: '平均耗时(ms)', width: 120, sorter: (a: any, b: any) => (a.avgLatencyMs ?? 0) - (b.avgLatencyMs ?? 0) },
{ colKey: 'faqHitRate', title: 'FAQ 命中率', width: 110, cell: (_: any, { row }: any) => `${((row.faqHitRate || 0) * 100).toFixed(1)}%`, sorter: (a: any, b: any) => (a.faqHitRate ?? 0) - (b.faqHitRate ?? 0) },
] ]
// ===== 详情状态 ===== // ===== 详情状态 =====
@ -299,16 +300,16 @@ const detailHistory = computed(() => parseJsonArray(detail.value?.historyMessage
const ragHitsColumns = [ const ragHitsColumns = [
{ colKey: 'title', title: '标题', ellipsis: true }, { colKey: 'title', title: '标题', ellipsis: true },
{ colKey: 'documentId', title: '文档 ID', width: 150, ellipsis: true }, { colKey: 'documentId', title: '文档 ID', width: 150, ellipsis: true },
{ colKey: 'chunkIndex', title: '分块', width: 70 },
{ colKey: 'score', title: '相似度', width: 90, cell: (_: any, { row }: any) => formatScore(row.score) },
{ colKey: 'searchMode', title: '检索模式', width: 100 },
{ colKey: 'chunkIndex', title: '分块', width: 70, sorter: (a: any, b: any) => (a.chunkIndex ?? 0) - (b.chunkIndex ?? 0) },
{ colKey: 'score', title: '相似度', width: 90, cell: (_: any, { row }: any) => formatScore(row.score), sorter: (a: any, b: any) => (a.score ?? 0) - (b.score ?? 0) },
{ colKey: 'searchMode', title: '检索模式', width: 100, sorter: true },
] ]
const toolCallsColumns = [ const toolCallsColumns = [
{ colKey: 'tool', title: '工具', width: 150, ellipsis: true },
{ colKey: 'tool', title: '工具', width: 150, ellipsis: true, sorter: true },
{ colKey: 'input', title: '输入', ellipsis: true }, { colKey: 'input', title: '输入', ellipsis: true },
{ colKey: 'result', title: '结果', ellipsis: true }, { colKey: 'result', title: '结果', ellipsis: true },
{ colKey: 'latencyMs', title: '耗时', width: 90 },
{ colKey: 'latencyMs', title: '耗时', width: 90, sorter: (a: any, b: any) => (a.latencyMs ?? 0) - (b.latencyMs ?? 0) },
{ colKey: 'error', title: '错误', width: 70 }, { colKey: 'error', title: '错误', width: 70 },
] ]
@ -434,6 +435,8 @@ async function loadList() {
keyword: filterKeyword.value || undefined, keyword: filterKeyword.value || undefined,
apiKeyId: filterApiKeyId.value || undefined, apiKeyId: filterApiKeyId.value || undefined,
errorType: filterErrorType.value || undefined, errorType: filterErrorType.value || undefined,
sortField: sortInfo.value?.sortBy || undefined,
sortOrder: sortInfo.value ? (sortInfo.value.descending ? 'desc' : 'asc') : undefined,
}) })
if (r.success) { if (r.success) {
traces.value = r.data?.records || r.data || [] traces.value = r.data?.records || r.data || []
@ -454,6 +457,7 @@ function onPageChange(info: { current: number; pageSize: number }) {
selectedRowKeys.value = [] selectedRowKeys.value = []
loadList() loadList()
} }
function onSortChange(s: any) { sortInfo.value = s && s.sortBy ? { sortBy: s.sortBy, descending: !!s.descending } : null; loadList() }
function onFilterChange() { function onFilterChange() {
page.value = 1 page.value = 1

20
frontend/src/views/SensitiveWordManager.vue

@ -12,8 +12,8 @@
<span class="total-hint">共 {{ total }} 个</span> <span class="total-hint">共 {{ total }} 个</span>
</div> </div>
<t-table :data="words" :columns="columns" row-key="id" :loading="loading"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange">
<BaseTable :data="words" :columns="columns" row-key="id" :loading="loading" :sort="sortInfo"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange" @sort-change="onSortChange">
<template #category="{ row }"> <template #category="{ row }">
<t-tag size="small" :style="{ background: catColor(row.category) }" variant="light">{{ catLabel(row.category) }}</t-tag> <t-tag size="small" :style="{ background: catColor(row.category) }" variant="light">{{ catLabel(row.category) }}</t-tag>
</template> </template>
@ -29,7 +29,7 @@
<t-button size="small" variant="text" theme="danger" @click="doDelete(row)">删除</t-button> <t-button size="small" variant="text" theme="danger" @click="doDelete(row)">删除</t-button>
</t-space> </t-space>
</template> </template>
</t-table>
</BaseTable>
<!-- 新增/编辑弹窗 --> <!-- 新增/编辑弹窗 -->
<t-dialog v-model:visible="showFormDialog" :header="editingId ? '编辑敏感词' : '添加敏感词'" width="480px" :footer="false"> <t-dialog v-model:visible="showFormDialog" :header="editingId ? '编辑敏感词' : '添加敏感词'" width="480px" :footer="false">
@ -114,14 +114,15 @@ const importCategory = ref('custom')
const importLevel = ref(1) const importLevel = ref(1)
const columns = [ const columns = [
{ colKey: 'word', title: '敏感词', width: 160, ellipsis: true },
{ colKey: 'category', title: '分类', width: 100 },
{ colKey: 'level', title: '级别', width: 80 },
{ colKey: 'isActive', title: '状态', width: 70 },
{ colKey: 'word', title: '敏感词', width: 160, ellipsis: true, sorter: true },
{ colKey: 'category', title: '分类', width: 100, sorter: true },
{ colKey: 'level', title: '级别', width: 80, sorter: true },
{ colKey: 'isActive', title: '状态', width: 70, sorter: true },
{ colKey: 'remark', title: '备注', width: 140, ellipsis: true }, { colKey: 'remark', title: '备注', width: 140, ellipsis: true },
{ colKey: 'updateTime', title: '更新时间', width: 160, cell: (_: any, { row }: any) => formatDate(row.updateTime) },
{ colKey: 'updateTime', title: '更新时间', width: 160, sorter: true, cell: (_: any, { row }: any) => formatDate(row.updateTime) },
{ colKey: 'op', title: '操作', width: 120 }, { colKey: 'op', title: '操作', width: 120 },
] ]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
onMounted(() => loadList()) onMounted(() => loadList())
@ -129,7 +130,7 @@ async function loadList() {
loading.value = true loading.value = true
try { try {
const level = filterLevel.value ? Number(filterLevel.value) : undefined const level = filterLevel.value ? Number(filterLevel.value) : undefined
const r = await listSensitiveWords(page.value, pageSize.value, searchKeyword.value || undefined, filterCategory.value || undefined, level)
const r = await listSensitiveWords(page.value, pageSize.value, searchKeyword.value || undefined, filterCategory.value || undefined, level, sortInfo.value?.sortBy || undefined, sortInfo.value ? (sortInfo.value.descending ? 'desc' : 'asc') : undefined)
if (r.success) { if (r.success) {
const list = r.data?.records || r.data || [] const list = r.data?.records || r.data || []
words.value = list.map((w: any) => ({ ...w, _active: w.isActive })) words.value = list.map((w: any) => ({ ...w, _active: w.isActive }))
@ -147,6 +148,7 @@ function onPageChange(info: { current: number; pageSize: number }) {
pageSize.value = info.pageSize pageSize.value = info.pageSize
loadList() loadList()
} }
function onSortChange(s: any) { sortInfo.value = s && s.sortBy ? { sortBy: s.sortBy, descending: !!s.descending } : null; loadList() }
function onFilterChange() { function onFilterChange() {
page.value = 1 page.value = 1

19
frontend/src/views/UserManager.vue

@ -9,8 +9,8 @@
<t-button theme="primary" size="small" @click="openCreate">+ 新建用户</t-button> <t-button theme="primary" size="small" @click="openCreate">+ 新建用户</t-button>
</div> </div>
<t-table :data="users" :columns="columns" row-key="id"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange">
<BaseTable :data="users" :columns="columns" row-key="id" :loading="loading" :sort="sortInfo"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange" @sort-change="onSortChange">
<template #role_names="{ row }"> <template #role_names="{ row }">
<t-tag v-for="r in (row.role_names||'').split(',').filter(Boolean)" :key="r" size="small" variant="light" theme="primary" style="margin-right:2px;">{{ r }}</t-tag> <t-tag v-for="r in (row.role_names||'').split(',').filter(Boolean)" :key="r" size="small" variant="light" theme="primary" style="margin-right:2px;">{{ r }}</t-tag>
<t-tag v-if="!row.role_names" size="small" variant="light" theme="default">未分配</t-tag> <t-tag v-if="!row.role_names" size="small" variant="light" theme="default">未分配</t-tag>
@ -26,7 +26,7 @@
<t-button size="small" variant="text" :theme="row.enabled?'warning':'success'" @click="handleToggle(row)">{{ row.enabled?'禁用':'启用' }}</t-button> <t-button size="small" variant="text" :theme="row.enabled?'warning':'success'" @click="handleToggle(row)">{{ row.enabled?'禁用':'启用' }}</t-button>
</t-space> </t-space>
</template> </template>
</t-table>
</BaseTable>
<!-- 新建/编辑弹窗 --> <!-- 新建/编辑弹窗 -->
<t-dialog v-model:visible="modal.visible" :header="modal.isEdit?'编辑用户':'新建用户'" width="480px" :footer="false"> <t-dialog v-model:visible="modal.visible" :header="modal.isEdit?'编辑用户':'新建用户'" width="480px" :footer="false">
@ -75,14 +75,15 @@ import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm() const { confirm } = useConfirm()
const users = ref<any[]>([]); const total=ref(0); const page=ref(1); const pageSize=ref(20) const users = ref<any[]>([]); const total=ref(0); const page=ref(1); const pageSize=ref(20)
const keyword=ref(''); const filterEnabled=ref<boolean|null>(null); const allRoles=ref<any[]>([]); const saving=ref(false)
const keyword=ref(''); const filterEnabled=ref<boolean|null>(null); const allRoles=ref<any[]>([]); const saving=ref(false); const loading=ref(false)
const statusOpts=[{label:'全部状态',value:null},{label:'已启用',value:true},{label:'已禁用',value:false}] const statusOpts=[{label:'全部状态',value:null},{label:'已启用',value:true},{label:'已禁用',value:false}]
const columns = [ const columns = [
{colKey:'username',title:'用户名',width:100},{colKey:'nickname',title:'昵称',width:80},{colKey:'email',title:'邮箱',width:140},
{colKey:'role_names',title:'角色',width:160},{colKey:'enabled',title:'状态',width:60},{colKey:'last_login_time',title:'最后登录',width:160,cell:(_:any,{row}:any)=>formatDate(row.last_login_time)},
{colKey:'create_time',title:'创建时间',width:160,cell:(_:any,{row}:any)=>formatDate(row.create_time)},{colKey:'op',title:'操作',width:220},
{colKey:'username',title:'用户名',width:100,sorter:true},{colKey:'nickname',title:'昵称',width:80,sorter:true},{colKey:'email',title:'邮箱',width:140,sorter:true},
{colKey:'role_names',title:'角色',width:160},{colKey:'enabled',title:'状态',width:60,sorter:true},{colKey:'last_login_time',title:'最后登录',width:160,sorter:true,cell:(_:any,{row}:any)=>formatDate(row.last_login_time)},
{colKey:'create_time',title:'创建时间',width:160,sorter:true,cell:(_:any,{row}:any)=>formatDate(row.create_time)},{colKey:'op',title:'操作',width:220},
] ]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
const modal = reactive({visible:false,isEdit:false,userId:null as any,username:'',password:'',nickname:'',email:'',phone:''}) const modal = reactive({visible:false,isEdit:false,userId:null as any,username:'',password:'',nickname:'',email:'',phone:''})
const roleModal = reactive({visible:false,userId:null as any,username:'',selectedRoleIds:[] as string[]}) const roleModal = reactive({visible:false,userId:null as any,username:'',selectedRoleIds:[] as string[]})
@ -90,9 +91,11 @@ const pwdModal = reactive({visible:false,userId:null as any,username:'',newPassw
onMounted(()=>{loadData();loadRoles()}) onMounted(()=>{loadData();loadRoles()})
async function loadData(){try{const r=await listSysUsers(page.value,pageSize.value,keyword.value,filterEnabled.value as any);if(r.success){users.value=r.data.records||[];total.value=r.data.total||0}}catch(e){console.error(e)}}
async function loadData(){loading.value=true
try{const r=await listSysUsers(page.value,pageSize.value,keyword.value,filterEnabled.value as any,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined);if(r.success){users.value=r.data.records||[];total.value=r.data.total||0}}catch(e){console.error(e)}finally{loading.value=false}}
async function loadRoles(){try{const r=await getAllSysRoles();if(r.success)allRoles.value=r.data||[]}catch(e){console.error(e)}} async function loadRoles(){try{const r=await getAllSysRoles();if(r.success)allRoles.value=r.data||[]}catch(e){console.error(e)}}
function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadData()} function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadData()}
function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;loadData()}
function openCreate(){Object.assign(modal,{visible:true,isEdit:false,userId:null,username:'',password:'',nickname:'',email:'',phone:''})} function openCreate(){Object.assign(modal,{visible:true,isEdit:false,userId:null,username:'',password:'',nickname:'',email:'',phone:''})}
function openEdit(u:any){Object.assign(modal,{visible:true,isEdit:true,userId:u.id,username:u.username,nickname:u.nickname||'',email:u.email||'',phone:u.phone||'',password:''})} function openEdit(u:any){Object.assign(modal,{visible:true,isEdit:true,userId:u.id,username:u.username,nickname:u.nickname||'',email:u.email||'',phone:u.phone||'',password:''})}

12
frontend/src/views/WebhookManager.vue

@ -5,8 +5,8 @@
<div style="flex:1;" /><span class="total-hint">共 {{ total }} 个</span> <div style="flex:1;" /><span class="total-hint">共 {{ total }} 个</span>
</div> </div>
<t-table :data="webhooks" :columns="columns" row-key="id" :loading="loading"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange">
<BaseTable :data="webhooks" :columns="columns" row-key="id" :loading="loading" :sort="sortInfo"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange" @sort-change="onSortChange">
<template #events="{row}"> <template #events="{row}">
<t-tag v-for="ev in getEvents(row)" :key="ev" size="small" variant="light" style="margin-right:4px;">{{ ev }}</t-tag> <t-tag v-for="ev in getEvents(row)" :key="ev" size="small" variant="light" style="margin-right:4px;">{{ ev }}</t-tag>
</template> </template>
@ -18,7 +18,7 @@
<t-button size="small" variant="text" theme="danger" @click="doDelete(row.id)">删除</t-button> <t-button size="small" variant="text" theme="danger" @click="doDelete(row.id)">删除</t-button>
</t-space> </t-space>
</template> </template>
</t-table>
</BaseTable>
<t-dialog v-model:visible="showFormDialog" :header="editingId?'编辑 Webhook':'新建 Webhook'" width="520px" :footer="false"> <t-dialog v-model:visible="showFormDialog" :header="editingId?'编辑 Webhook':'新建 Webhook'" width="520px" :footer="false">
<t-form label-align="top"> <t-form label-align="top">
@ -55,15 +55,17 @@ const form=ref({name:'',url:'',events:[] as string[],enabled:true})
const availableEvents=[{value:'document.ready',label:'文档处理完成'},{value:'document.failed',label:'文档处理失败'},{value:'feedback.new',label:'收到用户反馈'}] const availableEvents=[{value:'document.ready',label:'文档处理完成'},{value:'document.failed',label:'文档处理失败'},{value:'feedback.new',label:'收到用户反馈'}]
const columns=[{colKey:'name',title:'名称',width:140},{colKey:'url',title:'URL',width:200,ellipsis:true},{colKey:'events',title:'订阅事件',width:200},{colKey:'enabled',title:'状态',width:80},{colKey:'op',title:'操作',width:160}]
const columns=[{colKey:'name',title:'名称',width:140,sorter:true},{colKey:'url',title:'URL',width:200,ellipsis:true,sorter:true},{colKey:'events',title:'订阅事件',width:200},{colKey:'enabled',title:'状态',width:80,sorter:true},{colKey:'op',title:'操作',width:160}]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
onMounted(()=>loadList()) onMounted(()=>loadList())
function getEvents(w:any){try{return Array.isArray(w.events)?w.events:typeof w.events==='string'?JSON.parse(w.events):[]}catch{return[]}} function getEvents(w:any){try{return Array.isArray(w.events)?w.events:typeof w.events==='string'?JSON.parse(w.events):[]}catch{return[]}}
async function loadList(){loading.value=true async function loadList(){loading.value=true
try{const r=await listWebhooks(page.value,pageSize.value);if(r.success){webhooks.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
try{const r=await listWebhooks(page.value,pageSize.value,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined);if(r.success){webhooks.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadList()} function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadList()}
function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;loadList()}
function openCreateDialog(){editingId.value=null;form.value={name:'',url:'',events:[],enabled:true};showFormDialog.value=true} function openCreateDialog(){editingId.value=null;form.value={name:'',url:'',events:[],enabled:true};showFormDialog.value=true}
function openEditDialog(w:any){editingId.value=w.id;form.value={name:w.name||'',url:w.url||'',events:getEvents(w),enabled:w.enabled};showFormDialog.value=true} function openEditDialog(w:any){editingId.value=w.id;form.value={name:w.name||'',url:w.url||'',events:getEvents(w),enabled:w.enabled};showFormDialog.value=true}

40
src/main/java/com/wok/supportbot/common/SortUtils.java

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

18
src/main/java/com/wok/supportbot/config/AsyncExecutorConfig.java

@ -33,4 +33,22 @@ public class AsyncExecutorConfig {
executor.initialize(); executor.initialize();
return executor; return executor;
} }
/**
* 文档异步处理线程池(分块 → 关键词提取 → 向量化)。
* 有界线程池 + CallerRunsPolicy:避免文件夹上传大量文件时
* 默认 {@code SimpleAsyncTaskExecutor} 每任务新建线程导致的线程爆炸,
* 同时限制并发向量化调用,避免打爆大模型限流。
*/
@Bean("documentExecutor")
public ThreadPoolTaskExecutor documentExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("doc-process-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
} }

78
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() {
@ -883,7 +930,9 @@ public class DatabaseInitConfig {
/** /**
* 为 vector_store 表添加全文检索支持(tsvector 列 + GIN 索引 + 触发器) * 为 vector_store 表添加全文检索支持(tsvector 列 + GIN 索引 + 触发器)
* 幂等操作:已存在则跳过
* 全幂等自愈:每一步都用幂等 SQL(ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS /
* CREATE OR REPLACE + DROP TRIGGER IF EXISTS),不做「列已存在就 return」的早退,
* 避免半途失败或触发器被手动删除后新文档 content_tsvector 恒为 NULL 的静默缺口。
*/ */
private void initVectorStoreFullTextSearch() { private void initVectorStoreFullTextSearch() {
try { try {
@ -893,19 +942,12 @@ public class DatabaseInitConfig {
return; return;
} }
// 检查 content_tsvector 列是否已存在
String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'vector_store' AND column_name = 'content_tsvector'";
Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class);
if (count != null && count > 0) {
return; // 已初始化
}
log.info("初始化 vector_store 全文检索支持");
log.info("为 vector_store 添加全文检索支持");
// 添加 tsvector 列(已存在则跳过)
jdbcTemplate.execute("ALTER TABLE vector_store ADD COLUMN IF NOT EXISTS content_tsvector tsvector");
// 添加 tsvector 列
jdbcTemplate.execute("ALTER TABLE vector_store ADD COLUMN content_tsvector tsvector");
// 填充已有数据
// 回填已有数据(幂等:只补 NULL 行)
jdbcTemplate.execute("UPDATE vector_store SET content_tsvector = to_tsvector('simple', coalesce(content, '')) WHERE content_tsvector IS NULL"); jdbcTemplate.execute("UPDATE vector_store SET content_tsvector = to_tsvector('simple', coalesce(content, '')) WHERE content_tsvector IS NULL");
// GIN 索引 // GIN 索引
@ -1460,6 +1502,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 +1515,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", "主键");

30
src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java

@ -13,10 +13,14 @@ import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.ai.openai.OpenAiEmbeddingOptions; import org.springframework.ai.openai.OpenAiEmbeddingOptions;
import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.retry.backoff.ExponentialBackOffPolicy; import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.support.RetryTemplate; import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -110,7 +114,8 @@ public class EmbeddingModelFactory {
if ("dashscope".equalsIgnoreCase(config.getProvider())) { if ("dashscope".equalsIgnoreCase(config.getProvider())) {
log.info("创建 DashScope EmbeddingModel: model={}, baseUrl={}", config.getModelName(), baseUrl); log.info("创建 DashScope EmbeddingModel: model={}, baseUrl={}", config.getModelName(), baseUrl);
DashScopeApi.Builder apiBuilder = DashScopeApi.builder() DashScopeApi.Builder apiBuilder = DashScopeApi.builder()
.apiKey(config.getApiKey());
.apiKey(config.getApiKey())
.restClientBuilder(timeoutRestClientBuilder());
// 团队版/私有化部署使用自定义 baseUrl // 团队版/私有化部署使用自定义 baseUrl
if (baseUrl != null && !baseUrl.isBlank()) { if (baseUrl != null && !baseUrl.isBlank()) {
apiBuilder.baseUrl(baseUrl); apiBuilder.baseUrl(baseUrl);
@ -147,6 +152,7 @@ public class EmbeddingModelFactory {
.apiKey(config.getApiKey()) .apiKey(config.getApiKey())
.baseUrl(baseUrl) .baseUrl(baseUrl)
.embeddingsPath(embeddingsPath) .embeddingsPath(embeddingsPath)
.restClientBuilder(timeoutRestClientBuilder())
.build(); .build();
OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder() OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder()
@ -223,6 +229,28 @@ public class EmbeddingModelFactory {
log.info("EmbeddingModel 缓存已清除"); log.info("EmbeddingModel 缓存已清除");
} }
/** 向量化模型连接超时:connect 10s 与聊天路径(ChatModelFactory)保持一致 */
private static final Duration EMBED_CONNECT_TIMEOUT = Duration.ofSeconds(10);
/**
* 向量化模型读取超时:embedding 为非流式单次请求,60s 足以容纳大体积批请求,
* 避免慢响应无限挂起占死 documentExecutor 线程
*/
private static final Duration EMBED_READ_TIMEOUT = Duration.ofSeconds(60);
/**
* 构建带显式 connect/read 超时的 RestClient(DashScope / OpenAI 兼容 embedding 客户端使用)。
* 否则第三方默认可能无限等待,长文档批量向量化时一次慢响应即拖垮整个任务
*/
private static RestClient.Builder timeoutRestClientBuilder() {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(EMBED_CONNECT_TIMEOUT)
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(EMBED_READ_TIMEOUT);
return RestClient.builder().requestFactory(requestFactory);
}
// ==================== F1: 连接测试 ==================== // ==================== F1: 连接测试 ====================
/** /**

16
src/main/java/com/wok/supportbot/config/VolcengineMultimodalEmbeddingModel.java

@ -12,9 +12,12 @@ import org.springframework.ai.embedding.EmbeddingResponseMetadata;
import org.springframework.ai.chat.metadata.DefaultUsage; import org.springframework.ai.chat.metadata.DefaultUsage;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.retry.support.RetryTemplate; import org.springframework.retry.support.RetryTemplate;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@ -39,6 +42,12 @@ public class VolcengineMultimodalEmbeddingModel implements EmbeddingModel {
private static final String EMBEDDINGS_PATH = "/embeddings/multimodal"; private static final String EMBEDDINGS_PATH = "/embeddings/multimodal";
/** 连接超时:与聊天路径(ChatModelFactory)保持一致 */
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
/** 读取超时:单条文本一次请求,60s 足以容纳慢冷启动/大体积文本,避免无限挂起占用文档处理线程 */
private static final Duration READ_TIMEOUT = Duration.ofSeconds(60);
private final String apiKey; private final String apiKey;
private final String baseUrl; private final String baseUrl;
private final String modelName; private final String modelName;
@ -54,8 +63,15 @@ public class VolcengineMultimodalEmbeddingModel implements EmbeddingModel {
this.modelName = modelName; this.modelName = modelName;
this.dimensions = dimensions; this.dimensions = dimensions;
this.retryTemplate = retryTemplate; this.retryTemplate = retryTemplate;
// 显式设置 connect/read 超时:embedding 为非流式单次请求,避免慢响应无限挂起占用文档处理线程
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(READ_TIMEOUT);
this.restClient = RestClient.builder() this.restClient = RestClient.builder()
.baseUrl(baseUrl) .baseUrl(baseUrl)
.requestFactory(requestFactory)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey) .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build(); .build();

6
src/main/java/com/wok/supportbot/controller/AiModelConfigController.java

@ -58,9 +58,11 @@ public class AiModelConfigController {
public ResponseEntity<Map<String, Object>> listConfigs( public ResponseEntity<Map<String, Object>> listConfigs(
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String appType) {
@RequestParam(required = false) String appType,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try { try {
Map<String, Object> result = aiModelConfigService.listConfigs(appType, page, size);
Map<String, Object> result = aiModelConfigService.listConfigs(appType, page, size, sortField, sortOrder);
Map<String, Object> data = new java.util.LinkedHashMap<>(); Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("success", true); data.put("success", true);
data.put("data", result.get("records")); data.put("data", result.get("records"));

6
src/main/java/com/wok/supportbot/controller/ApiKeyController.java

@ -39,9 +39,11 @@ public class ApiKeyController {
@PreAuthorize("hasRole('admin')") @PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> list( public ResponseEntity<Map<String, Object>> list(
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try { try {
Map<String, Object> result = apiKeyService.listKeys(page, size);
Map<String, Object> result = apiKeyService.listKeys(page, size, sortField, sortOrder);
Map<String, Object> data = new java.util.HashMap<>(); Map<String, Object> data = new java.util.HashMap<>();
data.put("success", true); data.put("success", true);

6
src/main/java/com/wok/supportbot/controller/ConversationController.java

@ -48,9 +48,11 @@ public class ConversationController {
@RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@RequestParam(required = false) String accountId, @RequestParam(required = false) String accountId,
@RequestParam(required = false) Long roleId) {
@RequestParam(required = false) Long roleId,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try { try {
Map<String, Object> result = conversationService.listConversations(page, size, keyword, accountId, roleId);
Map<String, Object> result = conversationService.listConversations(page, size, keyword, accountId, roleId, sortField, sortOrder);
Map<String, Object> data = new java.util.HashMap<>(); Map<String, Object> data = new java.util.HashMap<>();
data.put("success", true); data.put("success", true);
data.put("data", result.get("records")); data.put("data", result.get("records"));

87
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"));
@ -580,27 +623,28 @@ public class DocumentController {
} }
/** /**
* P1-2.2: 批量移动文档分类
* body: {ids: [Long], categoryId: Long | String}
* 注意:categoryId 兼容 String 类型,避免前端雪花 ID 经 parseInt 丢失精度
* 统一移动文档:目标为「分类根」或「具体目录」
* body: {ids: [Long], categoryId: Long|String, folderId: Long|String}
* 注意:categoryId/folderId 兼容 String 类型,避免前端雪花 ID 经 parseInt 丢失精度
*/ */
@PostMapping("/document/batch/move") @PostMapping("/document/batch/move")
@PreAuthorize("hasAnyRole('admin','kb_operator')") @PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> batchMoveDocuments(@RequestBody Map<String, Object> body) { public ResponseEntity<Map<String, Object>> batchMoveDocuments(@RequestBody Map<String, Object> body) {
try { try {
List<Long> ids = extractIds(body); List<Long> ids = extractIds(body);
Long categoryId = extractCategoryId(body);
Long categoryId = extractLong(body, "categoryId");
Long folderId = extractLong(body, "folderId");
if (ids.isEmpty()) { if (ids.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"success", false, "success", false,
"message", "请提供文档ID列表" "message", "请提供文档ID列表"
)); ));
} }
int updatedCount = documentService.batchMoveDocuments(ids, categoryId);
int updatedCount = documentService.moveDocuments(ids, categoryId, folderId);
int requestedCount = ids.size(); int requestedCount = ids.size();
String message; String message;
if (updatedCount == requestedCount) { if (updatedCount == requestedCount) {
message = String.format("已将 %d 个文档移动到目标分类", updatedCount);
message = String.format("已将 %d 个文档移动到目标位置", updatedCount);
} else { } else {
message = String.format("移动完成:成功 %d 个,%d 个文档未找到", message = String.format("移动完成:成功 %d 个,%d 个文档未找到",
updatedCount, requestedCount - updatedCount); updatedCount, requestedCount - updatedCount);
@ -613,27 +657,28 @@ public class DocumentController {
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.status(500).body(Map.of( return ResponseEntity.status(500).body(Map.of(
"success", false, "success", false,
"message", "批量移动失败:" + e.getMessage()
"message", "移动失败:" + e.getMessage()
)); ));
} }
} }
/** /**
* 从请求体中提取 categoryId,兼容 Number 和 String 两种类型
* 从请求体中提取指定 key 的 Long 值,兼容 Number 和 String 两种类型
* 前端雪花 ID 超出 JS Number.MAX_SAFE_INTEGER,需以 String 传递以避免精度丢失 * 前端雪花 ID 超出 JS Number.MAX_SAFE_INTEGER,需以 String 传递以避免精度丢失
* 缺失或空字符串时返回 null(由调用方按未指定处理)
*/ */
private Long extractCategoryId(Map<String, Object> body) {
Object raw = body.get("categoryId");
private Long extractLong(Map<String, Object> body, String key) {
Object raw = body.get(key);
if (raw == null) { if (raw == null) {
return 0L;
return null;
} }
if (raw instanceof Number) { if (raw instanceof Number) {
return ((Number) raw).longValue(); return ((Number) raw).longValue();
} }
if (raw instanceof String str) { if (raw instanceof String str) {
return str.isEmpty() ? 0L : Long.parseLong(str);
return str.isEmpty() ? null : Long.parseLong(str);
} }
return 0L;
return null;
} }
// ==================== 语义搜索 ==================== // ==================== 语义搜索 ====================

6
src/main/java/com/wok/supportbot/controller/FaqController.java

@ -36,9 +36,11 @@ public class FaqController {
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) String status) {
@RequestParam(required = false) String status,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try { try {
Map<String, Object> data = faqService.list(page, size, keyword, categoryId, status);
Map<String, Object> data = faqService.list(page, size, keyword, categoryId, status, sortField, sortOrder);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "查询成功", "message", "查询成功",

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

6
src/main/java/com/wok/supportbot/controller/LlmCallTraceController.java

@ -55,10 +55,12 @@ public class LlmCallTraceController {
@RequestParam(required = false) String startTime, @RequestParam(required = false) String startTime,
@RequestParam(required = false) String endTime, @RequestParam(required = false) String endTime,
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@RequestParam(required = false) String errorType) {
@RequestParam(required = false) String errorType,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try { try {
Map<String, Object> result = llmCallTraceService.pageQuery( Map<String, Object> result = llmCallTraceService.pageQuery(
page, size, roleId, conversationId, intent, startTime, endTime, keyword, errorType);
page, size, roleId, conversationId, intent, startTime, endTime, keyword, errorType, sortField, sortOrder);
Map<String, Object> data = new LinkedHashMap<>(); Map<String, Object> data = new LinkedHashMap<>();
data.put("success", true); data.put("success", true);
data.put("data", result.get("records")); data.put("data", result.get("records"));

17
src/main/java/com/wok/supportbot/controller/McpServerConfigController.java

@ -42,9 +42,11 @@ public class McpServerConfigController {
@PreAuthorize("hasRole('admin')") @PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> listConfigs( public ResponseEntity<Map<String, Object>> listConfigs(
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try { try {
Map<String, Object> result = mcpServerConfigService.listConfigs(page, size);
Map<String, Object> result = mcpServerConfigService.listConfigs(page, size, sortField, sortOrder);
Map<String, Object> data = new LinkedHashMap<>(); Map<String, Object> data = new LinkedHashMap<>();
data.put("success", true); data.put("success", true);
data.put("data", result.get("records")); data.put("data", result.get("records"));
@ -252,16 +254,21 @@ public class McpServerConfigController {
* 切换配置启用/禁用状态 * 切换配置启用/禁用状态
* *
* @param id 配置ID * @param id 配置ID
* @param body 请求体:{active: true/false}
* @param body 请求体(可选):{active: true/false}
* @param active query 兜底参数(可选):兼容不带 body 的旧调用方(active 放 query string)
* @return 操作结果 * @return 操作结果
*/ */
@PutMapping("/mcp-server/{id}/toggle") @PutMapping("/mcp-server/{id}/toggle")
@PreAuthorize("hasRole('admin')") @PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> toggleActive( public ResponseEntity<Map<String, Object>> toggleActive(
@PathVariable("id") Long id, @PathVariable("id") Long id,
@RequestBody Map<String, Boolean> body) {
@RequestBody(required = false) Map<String, Boolean> body,
@RequestParam(value = "active", required = false) Boolean activeParam) {
try { try {
Boolean active = body.get("active");
Boolean active = (body != null) ? body.get("active") : null;
if (active == null) {
active = activeParam;
}
if (active == null) { if (active == null) {
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"success", false, "success", false,

6
src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java

@ -79,10 +79,12 @@ public class MessageFeedbackController {
@RequestParam(required = false) String reasonCategory, @RequestParam(required = false) String reasonCategory,
@RequestParam(required = false) Boolean processed, @RequestParam(required = false) Boolean processed,
@RequestParam(required = false) String startDate, @RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate) {
@RequestParam(required = false) String endDate,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try { try {
Map<String, Object> data = messageFeedbackService.listConversationsByFeedback( Map<String, Object> data = messageFeedbackService.listConversationsByFeedback(
page, size, feedbackType, reasonCategory, processed, startDate, endDate);
page, size, feedbackType, reasonCategory, processed, startDate, endDate, sortField, sortOrder);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"data", data "data", data

22
src/main/java/com/wok/supportbot/controller/SensitiveWordController.java

@ -1,5 +1,6 @@
package com.wok.supportbot.controller; package com.wok.supportbot.controller;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.entity.SensitiveWord; import com.wok.supportbot.entity.SensitiveWord;
import com.wok.supportbot.service.SensitiveWordService; import com.wok.supportbot.service.SensitiveWordService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -27,6 +28,12 @@ public class SensitiveWordController {
@Autowired @Autowired
private JdbcTemplate jdbcTemplate; private JdbcTemplate jdbcTemplate;
/** 审计日志排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 */
private static final Map<String, String> AUDIT_LOG_SORT_COLUMNS = Map.of(
"createTime", "create_time",
"direction", "direction",
"actionTaken", "action_taken");
// ==================== 敏感词 CRUD ==================== // ==================== 敏感词 CRUD ====================
/** /**
@ -39,9 +46,11 @@ public class SensitiveWordController {
@RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@RequestParam(required = false) String category, @RequestParam(required = false) String category,
@RequestParam(required = false) Integer level) {
@RequestParam(required = false) Integer level,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try { try {
Map<String, Object> result = sensitiveWordService.list(page, size, keyword, category, level);
Map<String, Object> result = sensitiveWordService.list(page, size, keyword, category, level, sortField, sortOrder);
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("success", true); data.put("success", true);
@ -202,7 +211,9 @@ public class SensitiveWordController {
public ResponseEntity<Map<String, Object>> auditLog( public ResponseEntity<Map<String, Object>> auditLog(
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String sessionId) {
@RequestParam(required = false) String sessionId,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try { try {
// 构建 WHERE 子句 // 构建 WHERE 子句
StringBuilder whereClause = new StringBuilder("WHERE 1=1"); StringBuilder whereClause = new StringBuilder("WHERE 1=1");
@ -217,8 +228,11 @@ public class SensitiveWordController {
Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray()); Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray());
// 查询列表 // 查询列表
// 排序字段白名单解析(防 SQL 注入),默认按创建时间倒序
String sortColumn = SortUtils.resolveColumn(sortField, AUDIT_LOG_SORT_COLUMNS, "create_time");
String orderBy = sortColumn + (SortUtils.isAscending(sortOrder) ? " ASC" : " DESC");
String listSql = "SELECT * FROM content_audit_log " + whereClause + String listSql = "SELECT * FROM content_audit_log " + whereClause +
" ORDER BY create_time DESC LIMIT ? OFFSET ?";
" ORDER BY " + orderBy + " LIMIT ? OFFSET ?";
List<Object> queryParams = new java.util.ArrayList<>(params); List<Object> queryParams = new java.util.ArrayList<>(params);
queryParams.add(size); queryParams.add(size);
queryParams.add((long) (page - 1) * size); queryParams.add((long) (page - 1) * size);

6
src/main/java/com/wok/supportbot/controller/SysUserController.java

@ -32,9 +32,11 @@ public class SysUserController {
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@RequestParam(required = false) Boolean enabled) {
@RequestParam(required = false) Boolean enabled,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try { try {
Map<String, Object> data = sysUserService.listUsers(page, size, keyword, enabled);
Map<String, Object> data = sysUserService.listUsers(page, size, keyword, enabled, sortField, sortOrder);
return ResponseEntity.ok(Map.of("success", true, "data", data)); return ResponseEntity.ok(Map.of("success", true, "data", data));
} catch (Exception e) { } catch (Exception e) {
log.error("查询用户列表失败", e); log.error("查询用户列表失败", e);

6
src/main/java/com/wok/supportbot/controller/WebhookController.java

@ -36,9 +36,11 @@ public class WebhookController {
@PreAuthorize("hasRole('admin')") @PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> list( public ResponseEntity<Map<String, Object>> list(
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try { try {
Map<String, Object> result = webhookService.listWebhooks(page, size);
Map<String, Object> result = webhookService.listWebhooks(page, size, sortField, sortOrder);
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("success", true); data.put("success", true);

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> {
}

30
src/main/java/com/wok/supportbot/document/transform/MyKeywordEnricher.java

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

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;
/**
* 父目录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;
}

4
src/main/java/com/wok/supportbot/rag/HybridSearchService.java

@ -97,6 +97,9 @@ public class HybridSearchService {
* 全文关键词检索 * 全文关键词检索
* 使用 PostgreSQL tsvector 全文索引,按 ts_rank 排序。 * 使用 PostgreSQL tsvector 全文索引,按 ts_rank 排序。
* P1-2.1: 始终过滤禁用的文档(metadata->>'enabled' = 'true') * P1-2.1: 始终过滤禁用的文档(metadata->>'enabled' = 'true')
* 注意:vector_store 为物理删除(无 is_delete 列),仅保证有
* id / content / metadata / embedding / content_tsvector / create_time 六列,
* 禁止在原生 SQL 中引用 is_delete、update_time 等业务表字段。
*/ */
private List<SearchResult> keywordSearch(String query, int topK, List<Long> categoryIds) { private List<SearchResult> keywordSearch(String query, int topK, List<Long> categoryIds) {
String categoryFilter = buildCategoryFilter(categoryIds); String categoryFilter = buildCategoryFilter(categoryIds);
@ -105,7 +108,6 @@ public class HybridSearchService {
ts_rank(content_tsvector, plainto_tsquery('simple', ?)) AS rank ts_rank(content_tsvector, plainto_tsquery('simple', ?)) AS rank
FROM vector_store FROM vector_store
WHERE content_tsvector @@ plainto_tsquery('simple', ?) WHERE content_tsvector @@ plainto_tsquery('simple', ?)
AND is_delete = false
AND metadata->>'enabled' = 'true' AND metadata->>'enabled' = 'true'
%s %s
ORDER BY rank DESC ORDER BY rank DESC

29
src/main/java/com/wok/supportbot/service/AiModelConfigService.java

@ -1,7 +1,9 @@
package com.wok.supportbot.service; package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.dao.AiModelConfigMapper; import com.wok.supportbot.dao.AiModelConfigMapper;
import com.wok.supportbot.entity.AiModelConfig; import com.wok.supportbot.entity.AiModelConfig;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -35,6 +37,14 @@ public class AiModelConfigService {
/** 活跃配置本地缓存:appType → AiModelConfig(含完整 API Key),写操作时自动失效 */ /** 活跃配置本地缓存:appType → AiModelConfig(含完整 API Key),写操作时自动失效 */
private final ConcurrentHashMap<String, AiModelConfig> activeConfigCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, AiModelConfig> activeConfigCache = new ConcurrentHashMap<>();
/** 模型配置列表排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 */
private static final Map<String, String> AI_MODEL_SORT_COLUMNS = Map.of(
"name", "name",
"app_type", "app_type",
"model_name", "model_name",
"status", "is_active",
"priority", "priority");
// ==================== 分页列表 ==================== // ==================== 分页列表 ====================
/** /**
@ -45,7 +55,7 @@ public class AiModelConfigService {
* @param size 每页大小 * @param size 每页大小
* @return 分页结果 * @return 分页结果
*/ */
public Map<String, Object> listConfigs(String appType, int page, int size) {
public Map<String, Object> listConfigs(String appType, int page, int size, String sortField, String sortOrder) {
// 构建查询条件(共用基础条件) // 构建查询条件(共用基础条件)
LambdaQueryWrapper<AiModelConfig> baseCondition = new LambdaQueryWrapper<>(); LambdaQueryWrapper<AiModelConfig> baseCondition = new LambdaQueryWrapper<>();
if (appType != null && !appType.isEmpty()) { if (appType != null && !appType.isEmpty()) {
@ -57,12 +67,21 @@ public class AiModelConfigService {
if (total == null) total = 0L; if (total == null) total = 0L;
// 查询列表(带排序和分页) // 查询列表(带排序和分页)
LambdaQueryWrapper<AiModelConfig> listWrapper = new LambdaQueryWrapper<>();
QueryWrapper<AiModelConfig> listWrapper = new QueryWrapper<>();
if (appType != null && !appType.isEmpty()) { if (appType != null && !appType.isEmpty()) {
listWrapper.eq(AiModelConfig::getAppType, appType);
listWrapper.eq("app_type", appType);
}
// 排序:未指定时保持 priority + create_time 双列默认,指定时按白名单单列
String sortColumn = sortField != null ? AI_MODEL_SORT_COLUMNS.get(sortField) : null;
if (sortColumn != null) {
if (SortUtils.isAscending(sortOrder)) {
listWrapper.orderByAsc(sortColumn);
} else {
listWrapper.orderByDesc(sortColumn);
}
} else {
listWrapper.orderByDesc("priority").orderByDesc("create_time");
} }
listWrapper.orderByDesc(AiModelConfig::getPriority)
.orderByDesc(AiModelConfig::getCreateTime);
listWrapper.last("LIMIT " + size + " OFFSET " + ((page - 1) * size)); listWrapper.last("LIMIT " + size + " OFFSET " + ((page - 1) * size));
List<AiModelConfig> records = aiModelConfigMapper.selectList(listWrapper); List<AiModelConfig> records = aiModelConfigMapper.selectList(listWrapper);

17
src/main/java/com/wok/supportbot/service/ApiKeyService.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service; package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.common.SortUtils;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.dao.ApiKeyMapper; import com.wok.supportbot.dao.ApiKeyMapper;
@ -28,6 +29,13 @@ public class ApiKeyService {
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
/** API Key 列表排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 */
private static final Map<String, String> API_KEY_SORT_COLUMNS = Map.of(
"name", "name",
"rateLimit", "rate_limit",
"enabled", "enabled",
"createTime", "create_time");
/** /**
* 生成新的 API Key * 生成新的 API Key
* *
@ -68,12 +76,17 @@ public class ApiKeyService {
* @param size 每页条数 * @param size 每页条数
* @return 包含 records/total/page/size/pages 的结果 * @return 包含 records/total/page/size/pages 的结果
*/ */
public Map<String, Object> listKeys(int page, int size) {
public Map<String, Object> listKeys(int page, int size, String sortField, String sortOrder) {
QueryWrapper<ApiKey> countWrapper = new QueryWrapper<>(); QueryWrapper<ApiKey> countWrapper = new QueryWrapper<>();
Long total = apiKeyMapper.selectCount(countWrapper); Long total = apiKeyMapper.selectCount(countWrapper);
QueryWrapper<ApiKey> listWrapper = new QueryWrapper<>(); QueryWrapper<ApiKey> listWrapper = new QueryWrapper<>();
listWrapper.orderByDesc("create_time");
String sortColumn = SortUtils.resolveColumn(sortField, API_KEY_SORT_COLUMNS, "create_time");
if (SortUtils.isAscending(sortOrder)) {
listWrapper.orderByAsc(sortColumn);
} else {
listWrapper.orderByDesc(sortColumn);
}
listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size); listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size);
List<ApiKey> records = apiKeyMapper.selectList(listWrapper); List<ApiKey> records = apiKeyMapper.selectList(listWrapper);

24
src/main/java/com/wok/supportbot/service/ConversationService.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service; package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.dao.ChatMessageMapper; import com.wok.supportbot.dao.ChatMessageMapper;
import com.wok.supportbot.entity.ChatMessage; import com.wok.supportbot.entity.ChatMessage;
import com.wok.supportbot.entity.MessageFeedback; import com.wok.supportbot.entity.MessageFeedback;
@ -28,6 +29,15 @@ public class ConversationService {
@Autowired @Autowired
private JdbcTemplate jdbcTemplate; private JdbcTemplate jdbcTemplate;
/** 会话列表排序字段白名单(前端 colKey -> SQL 列名/聚合别名),防 SQL 注入 */
private static final Map<String, String> CONVERSATION_SORT_COLUMNS = Map.of(
"conversationId", "cm1.conversation_id",
"accountId", "cs.external_account_id",
"roleName", "csr.name",
"messageCount", "message_count",
"firstMessageTime", "first_message_time",
"lastMessageTime", "last_message_time");
// ==================== 会话列表 ==================== // ==================== 会话列表 ====================
/** /**
@ -39,7 +49,7 @@ public class ConversationService {
* @return 分页会话列表 * @return 分页会话列表
*/ */
public Map<String, Object> listConversations(int page, int size, String keyword) { public Map<String, Object> listConversations(int page, int size, String keyword) {
return listConversations(page, size, keyword, null, null);
return listConversations(page, size, keyword, null, null, null, null);
} }
/** /**
@ -47,7 +57,8 @@ public class ConversationService {
* 会话归属通过 conversation_session 表的 accountId / roleId 关联, * 会话归属通过 conversation_session 表的 accountId / roleId 关联,
* 管理员可通过上述字段按需筛选。 * 管理员可通过上述字段按需筛选。
*/ */
public Map<String, Object> listConversations(int page, int size, String keyword, String accountId, Long roleId) {
public Map<String, Object> listConversations(int page, int size, String keyword, String accountId, Long roleId,
String sortField, String sortOrder) {
StringBuilder whereClause = new StringBuilder("WHERE cm1.is_delete = false "); StringBuilder whereClause = new StringBuilder("WHERE cm1.is_delete = false ");
List<Object> params = new ArrayList<>(); List<Object> params = new ArrayList<>();
@ -86,6 +97,9 @@ public class ConversationService {
Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray()); Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray());
if (total == null) total = 0L; if (total == null) total = 0L;
// 排序字段白名单解析(防 SQL 注入),默认按最后消息时间倒序
String sortColumn = SortUtils.resolveColumn(sortField, CONVERSATION_SORT_COLUMNS, "last_message_time");
String orderBy = sortColumn + (SortUtils.isAscending(sortOrder) ? " ASC" : " DESC");
String listSql = """ String listSql = """
SELECT SELECT
cm1.conversation_id, cm1.conversation_id,
@ -105,9 +119,9 @@ public class ConversationService {
LEFT JOIN customer_service_role csr ON csr.id = cs.role_id AND csr.is_delete = false LEFT JOIN customer_service_role csr ON csr.id = cs.role_id AND csr.is_delete = false
""" + whereClause + """ """ + whereClause + """
GROUP BY cm1.conversation_id, cs.external_account_id, cs.role_id, csr.name GROUP BY cm1.conversation_id, cs.external_account_id, cs.role_id, csr.name
ORDER BY last_message_time DESC
ORDER BY %s
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
""";
""".formatted(orderBy);
List<Object> listParams = new ArrayList<>(params); List<Object> listParams = new ArrayList<>(params);
listParams.add(size); listParams.add(size);
@ -470,7 +484,7 @@ public class ConversationService {
empty.put("pages", 0L); empty.put("pages", 0L);
return empty; return empty;
} }
return listConversations(page, size, null, accountId, roleId);
return listConversations(page, size, null, accountId, roleId, null, null);
} }
/** /**

437
src/main/java/com/wok/supportbot/service/DocumentProcessingService.java

@ -1,40 +1,57 @@
package com.wok.supportbot.service; package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.wok.supportbot.dao.KnowledgeDocumentMapper; import com.wok.supportbot.dao.KnowledgeDocumentMapper;
import com.wok.supportbot.document.transform.MyKeywordEnricher;
import com.wok.supportbot.document.transform.MyTokenTextSplitter; import com.wok.supportbot.document.transform.MyTokenTextSplitter;
import com.wok.supportbot.entity.KnowledgeDocument; import com.wok.supportbot.entity.KnowledgeDocument;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document; import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Supplier;
import java.util.stream.Collectors;
/** /**
* 文档异步处理服务 * 文档异步处理服务
* 负责文档的分块、关键词提取、向量化等耗时操作,在后台线程中执行
* 负责文档的分块与向量化(按批隔离、失败可感知、有限自动重试),在后台线程中执行
*/ */
@Service @Service
@Slf4j @Slf4j
public class DocumentProcessingService { public class DocumentProcessingService {
/** 一批向量化的分块数:控制单批请求体积与"炸点"范围(含 volcengine-vision 逐条串行路径) */
@Value("${knowledge.vector.batch-size:50}")
private int embedBatchSize = 50;
/**
* 文档级自动重试次数(不含首次尝试)。
* 注意:批内 EmbeddingModel 已自带 3 次指数退避重试(EmbeddingModelFactory.createRetryTemplate),
* 文档级重试叠加在最外层;文档级只对"整篇仍有失败批"触发,多数瞬时错误已在内层耗掉,外层命中率低。
* 若厂商限流严重,可调小本值或调大 RETRY_BASE_DELAY_MS 冷却时间
*/
private static final int MAX_DOC_LEVEL_RETRIES = 2;
/** 重试等待基数(ms),指数放大(2s → 4s) */
private static final long RETRY_BASE_DELAY_MS = 2_000L;
/** 连续 N 批失败即中止本轮,交给文档级重试,避免打爆全部批次 */
private static final int MAX_CONSECUTIVE_BATCH_FAILURES = 3;
@Autowired @Autowired
private KnowledgeDocumentMapper documentMapper; private KnowledgeDocumentMapper documentMapper;
@Autowired @Autowired
private MyTokenTextSplitter myTokenTextSplitter; private MyTokenTextSplitter myTokenTextSplitter;
@Autowired
private MyKeywordEnricher myKeywordEnricher;
@Autowired @Autowired
private VectorStore pgVectorVectorStore; private VectorStore pgVectorVectorStore;
@ -42,8 +59,9 @@ public class DocumentProcessingService {
private JdbcTemplate jdbcTemplate; private JdbcTemplate jdbcTemplate;
/** /**
* 异步处理文档:分块 → 关键词提取 → 向量化 → 更新状态
* 使用 REQUIRES_NEW 在独立事务中执行,避免与主事务冲突
* 异步处理文档:分块 → 向量化(分批入库)→ 更新状态
* 不加跨方法事务:向量化属于慢速网络调用,期间不占用数据库连接,
* 避免文件夹批量上传时多个异步任务把连接池占满导致连接超时。
* *
* @param docId 文档ID * @param docId 文档ID
* @param documents 已解析的原始文档列表 * @param documents 已解析的原始文档列表
@ -54,8 +72,7 @@ public class DocumentProcessingService {
* @param chunkSize 分块大小(可选,覆盖全局配置) * @param chunkSize 分块大小(可选,覆盖全局配置)
* @param overlap 重叠大小(可选,覆盖全局配置) * @param overlap 重叠大小(可选,覆盖全局配置)
*/ */
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
@Async("documentExecutor")
public void processDocumentAsync(Long docId, List<Document> documents, String sourceName, public void processDocumentAsync(Long docId, List<Document> documents, String sourceName,
String title, Long categoryId, List<String> tags, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) { Integer chunkSize, Integer overlap) {
@ -66,59 +83,22 @@ public class DocumentProcessingService {
return; return;
} }
try {
// 1. 分块处理(使用 per-doc 参数或全局配置)
List<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents, chunkSize, overlap);
DocMeta meta = DocMeta.of(docId, sourceName, title, categoryId, tags,
Boolean.TRUE.equals(doc.getEnabled()));
// 2. 为每个分块设置 metadata
for (int i = 0; i < splitDocuments.size(); i++) {
Document d = splitDocuments.get(i);
Map<String, Object> meta = new HashMap<>(d.getMetadata());
meta.put("documentId", String.valueOf(docId));
meta.put("chunkIndex", i);
meta.put("sourceName", sourceName);
meta.put("title", title);
if (categoryId != null && categoryId > 0) {
meta.put("categoryId", String.valueOf(categoryId));
}
if (tags != null && !tags.isEmpty()) {
meta.put("tags", tags);
}
// P1-2.1: 标记启用状态,用于 RAG 检索过滤
meta.put("enabled", String.valueOf(Boolean.TRUE.equals(doc.getEnabled())));
splitDocuments.set(i, new Document(d.getId(), d.getText(), meta));
}
// 3. 关键词提取
List<Document> enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments);
// 4. 向量化存储
pgVectorVectorStore.add(enrichedDocuments);
// 5. 更新状态为 READY
doc.setStatus("READY");
doc.setChunkCount(enrichedDocuments.size());
documentMapper.updateById(doc);
log.info("异步处理文档完成: id={}, title={}, chunks={}", docId, title, enrichedDocuments.size());
} catch (Exception e) {
doc.setStatus("FAILED");
doc.setErrorMessage(e.getMessage());
documentMapper.updateById(doc);
log.error("异步处理文档失败: id={}, title={}", docId, title, e);
}
// 新文档首次处理无需清理(尚无向量);若中途失败触发整体重试,框架内会先清残留向量再重建
runPipelineWithRetry(doc, meta, false,
() -> myTokenTextSplitter.splitDocuments(documents, chunkSize, overlap));
} }
/** /**
* 异步重新处理文档(重新分块 + 向量化)
* 异步重新处理文档(清理旧向量后按批重建向量化)
* 使用文档存储的 per-doc 分块参数(extraConfig),如无则使用全局配置 * 使用文档存储的 per-doc 分块参数(extraConfig),如无则使用全局配置
* *
* @param docId 文档ID * @param docId 文档ID
* @param documents 解析后的文档列表 * @param documents 解析后的文档列表
*/ */
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
@Async("documentExecutor")
public void reprocessDocumentAsync(Long docId, List<Document> documents) { public void reprocessDocumentAsync(Long docId, List<Document> documents) {
// 等待主事务提交,确保文档记录可见 // 等待主事务提交,确保文档记录可见
KnowledgeDocument doc = waitForDocument(docId); KnowledgeDocument doc = waitForDocument(docId);
@ -127,61 +107,265 @@ public class DocumentProcessingService {
return; return;
} }
try {
// 删除旧向量
String sql = "SELECT id::text FROM vector_store WHERE metadata->>'documentId' = ?";
List<String> oldIds = jdbcTemplate.queryForList(sql, String.class, String.valueOf(docId));
if (!oldIds.isEmpty()) {
pgVectorVectorStore.delete(oldIds);
}
DocMeta meta = DocMeta.fromDocument(doc);
// 从 extraConfig 读取 per-doc 分块参数
// reprocess 兼容:首轮就清理旧向量后按批重建;分块参数从 extraConfig 读取
runPipelineWithRetry(doc, meta, true, () -> {
Integer chunkSize = null; Integer chunkSize = null;
Integer overlap = null; Integer overlap = null;
if (doc.getExtraConfig() != null) { if (doc.getExtraConfig() != null) {
Object cs = doc.getExtraConfig().get("chunkSize");
Object ol = doc.getExtraConfig().get("overlap");
if (cs instanceof Number) chunkSize = ((Number) cs).intValue();
if (ol instanceof Number) overlap = ((Number) ol).intValue();
if (doc.getExtraConfig().get("chunkSize") instanceof Number cs) chunkSize = cs.intValue();
if (doc.getExtraConfig().get("overlap") instanceof Number ol) overlap = ol.intValue();
}
return myTokenTextSplitter.splitDocuments(documents, chunkSize, overlap);
});
}
/**
* 文档级处理框架(外层兜底):切分 → 分批向量化 → 失败自动整体重试有限次数。
* 外层 try-catch 保证任何未预期异常下文档状态都从 PROCESSING 收敛到 FAILED,
* 避免 @Async 异常被 Spring 静默吞掉导致文档永久悬挂(前端轮询永不结束)
*/
private void runPipelineWithRetry(KnowledgeDocument doc, DocMeta meta,
boolean cleanBeforeFirstAttempt,
Supplier<List<Document>> splitter) {
try {
doPipelineWithRetry(doc, meta, cleanBeforeFirstAttempt, splitter);
} catch (Exception e) {
// 兜底落 FAILED;若状态写入也失败(如 DB 抖动),仅记日志,避免再次上抛造成悬挂
log.error("文档向量化处理出现未捕获异常: id={}", doc.getId(), e);
try {
String reason = e.getMessage() == null
? e.getClass().getSimpleName()
: truncate(e.getMessage());
patchStatus(doc.getId(), "FAILED", safeCountVectors(String.valueOf(doc.getId())),
"内部错误: " + reason);
} catch (Exception ex) {
log.error("兜底标记文档处理失败状态也失败: id={}, error={}", doc.getId(), ex.getMessage(), ex);
}
}
}
private void doPipelineWithRetry(KnowledgeDocument doc, DocMeta meta,
boolean cleanBeforeFirstAttempt,
Supplier<List<Document>> splitter) {
String docIdStr = String.valueOf(doc.getId());
for (int attempt = 0; attempt <= MAX_DOC_LEVEL_RETRIES; attempt++) {
// 每次尝试前复查文档仍存在(用户可能在重试等待/sleep 期间删除文档),避免向已删文档写孤儿向量
if (documentMapper.selectById(doc.getId()) == null) {
log.info("文档在处理期间已被删除,放弃处理: id={}", doc.getId());
return;
}
// 重试前:指数等待 + 清理上一轮残留向量,避免 split 重建产生新 Document.id 造成重复向量
if (attempt > 0) {
if (!sleepBackoff(attempt)) {
patchStatus(doc.getId(), "FAILED", safeCountVectors(docIdStr), "处理被中断");
return;
}
deleteVectorsByDocumentId(docIdStr);
log.warn("文档向量化整体重试第 {}/{} 次: id={}", attempt, MAX_DOC_LEVEL_RETRIES, doc.getId());
} else if (cleanBeforeFirstAttempt) {
deleteVectorsByDocumentId(docIdStr);
} }
// 重新分块(使用 per-doc 参数或全局配置)
List<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents, chunkSize, overlap);
List<Document> chunks;
try {
chunks = splitter.get();
} catch (Exception e) {
if (attempt < MAX_DOC_LEVEL_RETRIES && isRetryable(e.getMessage())) {
continue;
}
markFailed(doc, "文档分块失败: " + truncate(e.getMessage()));
return;
}
if (chunks == null || chunks.isEmpty()) {
// 分块为空属于内容/参数问题,重试不会自愈
markFailed(doc, "文档分块结果为空(无可向量化文本或小于最小分块),请检查内容与分块参数");
return;
}
List<Document> annotated = annotateChunks(chunks, meta);
BatchResult result = vectorizeInBatches(docIdStr, annotated, doc.getId());
// 处理期间文档被删除:终止且不再写状态(删除是用户明确意图,不覆盖为 FAILED)
if (result.cancelled) {
return;
}
int stored = safeCountVectors(docIdStr);
if (!result.hasFailure()) {
patchStatus(doc.getId(), "READY", stored, null);
log.info("文档向量化成功: id={}, title={}, chunks={}", doc.getId(), doc.getTitle(), stored);
return;
}
// 有失败批:若仍有余量且错误属瞬时/基础设施类,则整篇重试
if (attempt < MAX_DOC_LEVEL_RETRIES && isRetryable(result.firstError)) {
log.warn("存在失败批,准备整体重试: id={}, total={}, stored={}, firstError={}",
doc.getId(), result.totalChunks, stored, result.firstError);
continue;
}
// 已入库块保留在库(chunk_count 记实际入库数),缺失区间聚合进 error_message
markFailed(doc, result.buildSummary(stored));
return;
}
}
/**
* 分批向量化入库:按 embedBatchSize 逐批 add(),单批失败仅记录缺失区间后继续;
* 连续失败超过阈值且错误可重试时中止本轮(交给文档级重试),避免打满全部批次
*
* @return 各批结果聚合(成功批已写入 DB)
*/
private BatchResult vectorizeInBatches(String docIdStr, List<Document> annotated, Long docId) {
BatchResult result = new BatchResult(annotated.size());
int consecutiveFailures = 0;
int batchSize = effectiveBatchSize();
for (int start = 0; start < annotated.size(); start += batchSize) {
// 批前复查:文档若在异步处理期间被删除,立即终止后续写入,避免在 vector_store 留下孤儿向量
if (docId != null && documentMapper.selectById(docId) == null) {
log.info("处理期间文档已被删除,终止剩余批次: docId={}", docIdStr);
result.cancelled = true;
break;
}
int end = Math.min(start + batchSize, annotated.size());
List<Document> batch = annotated.subList(start, end);
try {
pgVectorVectorStore.add(batch);
consecutiveFailures = 0;
log.info("批次向量化成功: docId={}, 第{}~{}块, 累计进度 {}/{}",
docIdStr, start + 1, end, Math.min(end, result.totalChunks), result.totalChunks);
} catch (Exception e) {
consecutiveFailures++;
// 缺失区间:0-based chunkIndex 闭区间 [start, end-1];错误消息截断防 error_message 过长/回显正文
result.recordFailure(start, end - 1, truncate(e.getMessage()));
log.error("批次向量化失败: docId={}, 第{}~{}块, error={}",
docIdStr, start + 1, end, truncate(e.getMessage()));
if (consecutiveFailures >= MAX_CONSECUTIVE_BATCH_FAILURES && isRetryable(e.getMessage())) {
result.aborted = true;
// 连续失败中止本轮:把尚未执行的后续批次区间一并记入,避免 error_message 缺失段不完整
if (end < annotated.size()) {
result.recordFailure(end, annotated.size() - 1, "连续失败已中止本轮,后续批次未执行");
}
break;
}
}
}
return result;
}
/**
* 为每个分块写入关联元数据:documentId/chunkIndex/sourceName/title/categoryId/tags/enabled
*/
private List<Document> annotateChunks(List<Document> splitDocuments, DocMeta meta) {
for (int i = 0; i < splitDocuments.size(); i++) { for (int i = 0; i < splitDocuments.size(); i++) {
Document d = splitDocuments.get(i); Document d = splitDocuments.get(i);
Map<String, Object> meta = new HashMap<>(d.getMetadata());
meta.put("documentId", String.valueOf(docId));
meta.put("chunkIndex", i);
meta.put("sourceName", doc.getSourceName());
meta.put("title", doc.getTitle());
if (doc.getCategoryId() != null && doc.getCategoryId() > 0) {
meta.put("categoryId", String.valueOf(doc.getCategoryId()));
Map<String, Object> m = new HashMap<>(d.getMetadata());
m.put("documentId", String.valueOf(meta.docId()));
m.put("chunkIndex", i);
m.put("sourceName", meta.sourceName());
m.put("title", meta.title());
if (meta.categoryId() != null && meta.categoryId() > 0) {
m.put("categoryId", String.valueOf(meta.categoryId()));
} }
if (doc.getTags() != null && doc.getTags().containsKey("tags")) {
meta.put("tags", doc.getTags().get("tags"));
if (meta.tagsValue() instanceof List<?> tags && !tags.isEmpty()) {
m.put("tags", tags);
}
// P1-2.1: 标记启用状态,用于 RAG 检索过滤
m.put("enabled", String.valueOf(meta.enabled()));
splitDocuments.set(i, new Document(d.getId(), d.getText(), m));
} }
// P1-2.1: 标记启用状态
meta.put("enabled", String.valueOf(Boolean.TRUE.equals(doc.getEnabled())));
splitDocuments.set(i, new Document(d.getId(), d.getText(), meta));
return splitDocuments;
} }
List<Document> enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments);
pgVectorVectorStore.add(enrichedDocuments);
// ==================== 私有工具 ====================
doc.setStatus("READY");
doc.setChunkCount(enrichedDocuments.size());
doc.setErrorMessage(null);
documentMapper.updateById(doc);
/** 仅 patch 状态/分块数/错误信息,避免覆盖用户在异步期间的并发编辑(如改标题/分类/toggle enabled) */
private void patchStatus(Long docId, String status, Integer chunkCount, String errorMessage) {
documentMapper.update(null, new LambdaUpdateWrapper<KnowledgeDocument>()
.eq(KnowledgeDocument::getId, docId)
.set(KnowledgeDocument::getStatus, status)
.set(KnowledgeDocument::getChunkCount, chunkCount)
.set(KnowledgeDocument::getErrorMessage, errorMessage));
}
log.info("异步重新处理文档成功: id={}, title={}, chunks={}", docId, doc.getTitle(), enrichedDocuments.size());
private void markFailed(KnowledgeDocument doc, String message) {
String docIdStr = String.valueOf(doc.getId());
patchStatus(doc.getId(), "FAILED", safeCountVectors(docIdStr), message);
log.error("文档向量化失败: id={}, title={}, error={}", doc.getId(), doc.getTitle(), message);
}
/** 统计实际已入库向量数(失败时容错,DB 抖动不阻断状态收敛) */
private int safeCountVectors(String docIdStr) {
try {
return countStoredVectors(docIdStr);
} catch (Exception e) { } catch (Exception e) {
doc.setStatus("FAILED");
doc.setErrorMessage(e.getMessage());
documentMapper.updateById(doc);
log.error("异步重新处理文档失败: id={}, title={}", docId, doc.getTitle(), e);
log.warn("统计已入库向量数失败: docId={}, error={}", docIdStr, e.getMessage());
return 0;
}
}
/** 截断错误消息,避免 error_message 过长或回显大段文档文本 */
private static String truncate(String msg) {
if (msg == null) {
return null;
}
return msg.length() <= 500 ? msg : msg.substring(0, 500) + "…(已截断)";
}
/** 实际已入库向量条数:以 DB 为准,天然吸收"失败批内偶发的部分写入" */
private int countStoredVectors(String docIdStr) {
Integer c = jdbcTemplate.queryForObject(
"SELECT count(*) FROM vector_store WHERE metadata->>'documentId' = ?",
Integer.class, docIdStr);
return c == null ? 0 : c;
}
/** 物理删除该文档在 vector_store 的全部向量(vector_store 无逻辑删除语义) */
private void deleteVectorsByDocumentId(String docIdStr) {
List<String> ids = jdbcTemplate.queryForList(
"SELECT id::text FROM vector_store WHERE metadata->>'documentId' = ?",
String.class, docIdStr);
if (!ids.isEmpty()) {
pgVectorVectorStore.delete(ids);
log.debug("清理旧向量: documentId={}, count={}", docIdStr, ids.size());
}
} }
private int effectiveBatchSize() {
return embedBatchSize > 0 ? embedBatchSize : 50;
}
/** 简单指数退避等待;被中断时复位中断标志并返回 false(放弃处理) */
private static boolean sleepBackoff(int attempt) {
long ms = RETRY_BASE_DELAY_MS * (1L << (attempt - 1));
try {
Thread.sleep(ms);
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
/** 仅对疑似瞬时/基础设施类错误做文档级重试,4xx 参数类错误不空转 */
private static boolean isRetryable(String msg) {
// 消息缺失的未知错误不做整篇重试(避免对 NPE 等非瞬时错误空转 2 次),仅对明确瞬时/限流特征重试
if (msg == null || msg.isBlank()) {
return false;
}
String m = msg.toLowerCase();
return m.contains("timeout") || m.contains("timed out") || m.contains("connection")
|| m.contains("429") || m.contains("too many request") || m.contains("rate limit")
|| m.contains(" 500") || m.contains(" 502") || m.contains(" 503") || m.contains(" 504")
|| m.contains("socket") || m.contains("i/o error") || m.contains("internal server error")
|| m.contains("service unavailable") || m.contains("bad gateway");
} }
/** /**
@ -209,4 +393,73 @@ public class DocumentProcessingService {
} }
return null; return null;
} }
/**
* 分批向量化结果聚合:成功批已入库;失败批记录缺失 chunkIndex 区间与首个错误
*/
private static final class BatchResult {
final int totalChunks;
/** 每项为 [start, end](0-based chunkIndex 闭区间),最多保留前 8 段 */
final List<int[]> failedRanges = new ArrayList<>();
String firstError;
boolean aborted;
/** 处理期间文档被删除时置位:调用方应终止且不再写状态 */
boolean cancelled;
BatchResult(int totalChunks) {
this.totalChunks = totalChunks;
}
void recordFailure(int start, int end, String error) {
failedRanges.add(new int[]{start, end});
if (firstError == null) {
firstError = error;
}
}
boolean hasFailure() {
return !failedRanges.isEmpty();
}
/** 例:已入库 320/400 块,缺失第 321~400 块 向量化失败: <原因> */
String buildSummary(int storedChunks) {
String ranges = failedRanges.stream().limit(8)
.map(r -> r[0] == r[1]
? "第 " + (r[0] + 1) + " 块"
: "第 " + (r[0] + 1) + "~" + (r[1] + 1) + " 块")
.collect(Collectors.joining("、"));
if (failedRanges.size() > 8) {
ranges += "…共 " + failedRanges.size() + " 段";
}
String msg = "已入库 " + storedChunks + "/" + totalChunks + " 块,缺失 " + ranges + " 向量化失败";
if (aborted) {
msg += "(连续失败已中止本轮)";
}
if (firstError != null) {
msg += ": " + firstError;
}
return msg;
}
}
/**
* 分块元数据值对象:统一两个异步入口(新上传/reprocess)的 metadata 标注逻辑
*/
private record DocMeta(Long docId, String sourceName, String title, Long categoryId,
Object tagsValue, boolean enabled) {
static DocMeta of(Long docId, String sourceName, String title, Long categoryId,
List<String> tags, boolean enabled) {
return new DocMeta(docId, sourceName, title, categoryId, tags, enabled);
}
static DocMeta fromDocument(KnowledgeDocument doc) {
Object tagsValue = null;
if (doc.getTags() != null && doc.getTags().containsKey("tags")) {
tagsValue = doc.getTags().get("tags");
}
return new DocMeta(doc.getId(), doc.getSourceName(), doc.getTitle(),
doc.getCategoryId(), tagsValue, Boolean.TRUE.equals(doc.getEnabled()));
}
}
} }

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

@ -7,11 +7,11 @@ import com.wok.supportbot.document.extract.JsonDocumentLoader;
import com.wok.supportbot.document.extract.MarkdownDocumentLoader; import com.wok.supportbot.document.extract.MarkdownDocumentLoader;
import com.wok.supportbot.document.extract.SimpleStringDocumentReader; import com.wok.supportbot.document.extract.SimpleStringDocumentReader;
import com.wok.supportbot.document.extract.TikaDocumentReader; import com.wok.supportbot.document.extract.TikaDocumentReader;
import com.wok.supportbot.document.transform.MyKeywordEnricher;
import com.wok.supportbot.document.transform.MyTokenTextSplitter; 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;
@ -55,9 +55,6 @@ public class DocumentService {
@Autowired @Autowired
private MyTokenTextSplitter myTokenTextSplitter; private MyTokenTextSplitter myTokenTextSplitter;
@Autowired
private MyKeywordEnricher myKeywordEnricher;
@Autowired @Autowired
private TikaDocumentReader tikaDocumentReader; private TikaDocumentReader tikaDocumentReader;
@ -73,6 +70,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 +97,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 +105,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 +129,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 +138,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 +148,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,26 +160,28 @@ 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();
// 2. 使用保存的文件进行解析(避免 MultipartFile 的临时文件问题) // 2. 使用保存的文件进行解析(避免 MultipartFile 的临时文件问题)
List<Document> documents = tikaDocumentReader.readFromFile(savedFile); List<Document> documents = tikaDocumentReader.readFromFile(savedFile);
String fileType = getFileExtension(file.getOriginalFilename());
// 剥离路径前缀:文件夹上传时浏览器 filename 携带相对路径(含根目录名),否则标题会带「目录/」前缀
String sourceName = stripPath(file.getOriginalFilename());
String fileType = getFileExtension(sourceName);
// 3. 创建文档记录并保存文件路径 // 3. 创建文档记录并保存文件路径
return uploadDocument(documents, return uploadDocument(documents,
title != null ? title : file.getOriginalFilename(),
file.getOriginalFilename(),
title != null ? title : sourceName,
sourceName,
fileType, fileType,
file.getSize(), file.getSize(),
documents.get(0).getText(), documents.get(0).getText(),
categoryId, categoryId,
tags, chunkSize, overlap, tags, chunkSize, overlap,
relativePath);
relativePath, folderId);
} }
/** /**
@ -201,18 +214,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 +242,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 +266,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 +291,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 +316,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 +457,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 +473,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 +501,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 +519,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);
@ -663,28 +806,67 @@ public class DocumentService {
jdbcTemplate.update(sql, enabled ? "true" : "false", documentId); jdbcTemplate.update(sql, enabled ? "true" : "false", documentId);
} }
// ==================== 批量移动分类 ====================
// ==================== 移动文档(分类根 / 目录) ====================
/** /**
* P1-2.2: 批量移动文档到目标分类
* 仅更新 category_id,不影响向量数据
* 统一移动文档:目标既可以是「分类根」(folderId=0),也可以是「具体目录」(folderId>0)。
* <p>约定与上传一致:目标为目录时,强制使用目录所属分类覆盖 categoryId;
* 目标为分类根时,使用传入的 categoryId(null/0 表示未分类)。</p>
* <p>categoryId 变更时同步 vector_store 的 categoryId metadata,保证 RAG 分类过滤正确。</p>
*/ */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public int batchMoveDocuments(List<Long> ids, Long categoryId) {
Long targetCategoryId = categoryId != null ? categoryId : 0L;
public int moveDocuments(List<Long> ids, Long categoryId, Long folderId) {
Long targetFolderId = (folderId != null && folderId > 0) ? folderId : 0L;
Long targetCategoryId;
if (targetFolderId > 0) {
KnowledgeFolder folder = folderService.getFolderById(targetFolderId);
if (folder == null) {
throw new RuntimeException("目标目录不存在");
}
targetCategoryId = folder.getCategoryId();
} else {
targetCategoryId = (categoryId != null) ? categoryId : 0L;
}
int updated = 0; int updated = 0;
for (Long id : ids) { for (Long id : ids) {
KnowledgeDocument doc = documentMapper.selectById(id); KnowledgeDocument doc = documentMapper.selectById(id);
if (doc != null) {
if (doc == null) {
continue;
}
Long oldCategoryId = doc.getCategoryId();
doc.setFolderId(targetFolderId);
doc.setCategoryId(targetCategoryId); doc.setCategoryId(targetCategoryId);
documentMapper.updateById(doc); documentMapper.updateById(doc);
updated++;
// 分类变更时同步向量 metadata 的 categoryId(folderId 不参与 RAG,无需同步)
if (oldCategoryId == null || !oldCategoryId.equals(targetCategoryId)) {
syncVectorCategoryMetadata(String.valueOf(id), targetCategoryId);
} }
updated++;
} }
log.info("批量移动文档分类: ids={}, categoryId={}, 实际更新={}", ids, targetCategoryId, updated);
log.info("移动文档: ids={}, folderId={}, categoryId={}, 实际更新={}", ids, targetFolderId, targetCategoryId, updated);
return updated; return updated;
} }
/**
* 同步 vector_store 中指定文档所有分块的 categoryId metadata。
* <p>与 {@link #syncVectorEnabledMetadata} 同构:metadata 列是 json 类型,用 jsonb 运算后转回 json。
* categoryId 在 DocumentProcessingService 中以字符串写入,故同步时写字符串;
* 目标为未分类(categoryId<=0)时移除该键(原写入逻辑仅在 categoryId>0 时写键)。</p>
*/
private void syncVectorCategoryMetadata(String documentId, Long categoryId) {
if (categoryId == null || categoryId <= 0) {
jdbcTemplate.update(
"UPDATE vector_store SET metadata = (metadata::jsonb - 'categoryId')::json WHERE metadata->>'documentId' = ?",
documentId);
} else {
jdbcTemplate.update(
"UPDATE vector_store SET metadata = (jsonb_set(metadata::jsonb, '{categoryId}', " +
"to_jsonb(?::text)))::json WHERE metadata->>'documentId' = ?",
String.valueOf(categoryId), documentId);
}
}
// ==================== 标签管理 ==================== // ==================== 标签管理 ====================
/** /**
@ -751,9 +933,7 @@ public class DocumentService {
meta.put("enabled", String.valueOf(Boolean.TRUE.equals(doc.getEnabled()))); meta.put("enabled", String.valueOf(Boolean.TRUE.equals(doc.getEnabled())));
Document newDoc = new Document(vectorId, newContent, meta); Document newDoc = new Document(vectorId, newContent, meta);
// 关键词提取
List<Document> enriched = myKeywordEnricher.enrichDocuments(List.of(newDoc));
pgVectorVectorStore.add(enriched);
pgVectorVectorStore.add(List.of(newDoc));
log.info("更新分块: docId={}, chunkIndex={}, vectorId={}", docId, chunkIndex, vectorId); log.info("更新分块: docId={}, chunkIndex={}, vectorId={}", docId, chunkIndex, vectorId);
} }
@ -1035,10 +1215,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 +1257,18 @@ public class DocumentService {
/** /**
* 检查内容是否重复 * 检查内容是否重复
* @param contentHash 内容哈希值 * @param contentHash 内容哈希值
* @param categoryId 分类ID
* @param folderId 目录ID(null/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;
@ -1105,6 +1291,20 @@ public class DocumentService {
/** /**
* 获取文件扩展名 * 获取文件扩展名
*/ */
/**
* 取文件名最后一段,剥离可能的路径前缀。
* 文件夹上传(webkitdirectory)时浏览器 filename 携带相对路径(含根目录名),
* 直接用作标题/源文件名会导致带「目录/」前缀。
*/
private String stripPath(String filename) {
if (filename == null || filename.isEmpty()) {
return filename;
}
String normalized = filename.replace('\\', '/');
int idx = normalized.lastIndexOf('/');
return idx >= 0 ? normalized.substring(idx + 1) : normalized;
}
private String getFileExtension(String filename) { private String getFileExtension(String filename) {
if (filename == null || !filename.contains(".")) { if (filename == null || !filename.contains(".")) {
return "unknown"; return "unknown";

21
src/main/java/com/wok/supportbot/service/FaqService.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service; package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.common.SortUtils;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.dao.FaqFeedbackLinkMapper; import com.wok.supportbot.dao.FaqFeedbackLinkMapper;
@ -41,6 +42,14 @@ public class FaqService {
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
/** FAQ 列表排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 */
private static final Map<String, String> FAQ_SORT_COLUMNS = Map.of(
"question", "question",
"priority", "priority",
"hitCount", "hit_count",
"status", "status",
"createTime", "create_time");
// ==================== 分页查询 ==================== // ==================== 分页查询 ====================
/** /**
@ -53,7 +62,7 @@ public class FaqService {
* @param status 状态过滤(可选) * @param status 状态过滤(可选)
* @return 分页结果 Map {total, records} * @return 分页结果 Map {total, records}
*/ */
public Map<String, Object> list(int page, int size, String keyword, Long categoryId, String status) {
public Map<String, Object> list(int page, int size, String keyword, Long categoryId, String status, String sortField, String sortOrder) {
if (page < 1) page = 1; if (page < 1) page = 1;
if (size < 1) size = 20; if (size < 1) size = 20;
if (size > 100) size = 100; if (size > 100) size = 100;
@ -72,7 +81,17 @@ public class FaqService {
Long total = faqMapper.selectCount(wrapper); Long total = faqMapper.selectCount(wrapper);
// 排序:未指定时保持 priority + create_time 双列默认,指定时按白名单单列
String sortColumn = sortField != null ? FAQ_SORT_COLUMNS.get(sortField) : null;
if (sortColumn != null) {
if (SortUtils.isAscending(sortOrder)) {
wrapper.orderByAsc(sortColumn);
} else {
wrapper.orderByDesc(sortColumn);
}
} else {
wrapper.orderByDesc("priority").orderByDesc("create_time"); wrapper.orderByDesc("priority").orderByDesc("create_time");
}
wrapper.last("LIMIT " + size + " OFFSET " + (page - 1) * size); wrapper.last("LIMIT " + size + " OFFSET " + (page - 1) * size);
List<KnowledgeFaq> records = faqMapper.selectList(wrapper); List<KnowledgeFaq> records = faqMapper.selectList(wrapper);

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 基础目录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;
}
}

24
src/main/java/com/wok/supportbot/service/LlmCallTraceService.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service; package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.dao.LlmCallTraceMapper; import com.wok.supportbot.dao.LlmCallTraceMapper;
import com.wok.supportbot.entity.LlmCallTrace; import com.wok.supportbot.entity.LlmCallTrace;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -43,6 +44,17 @@ public class LlmCallTraceService {
Set.of("system_prompt", "global_prompt", "role_prompt", "rag_context", "ai_response", Set.of("system_prompt", "global_prompt", "role_prompt", "rag_context", "ai_response",
"error_message", "tool_calls_json", "rag_hits_json", "history_messages_json"); "error_message", "tool_calls_json", "rag_hits_json", "history_messages_json");
/** LLM 调用追踪列表排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 */
private static final Map<String, String> LLM_TRACE_SORT_COLUMNS = Map.of(
"createTime", "create_time",
"roleName", "role_name",
"intent", "intent",
"modelName", "model_name",
"latencyMs", "latency_ms",
"faqHit", "faq_hit",
"status", "status",
"enableRag", "enable_rag");
/** 默认保留天数 */ /** 默认保留天数 */
private static final int DEFAULT_RETENTION_DAYS = 30; private static final int DEFAULT_RETENTION_DAYS = 30;
@ -66,7 +78,7 @@ public class LlmCallTraceService {
*/ */
public Map<String, Object> pageQuery(int page, int size, Long roleId, String conversationId, public Map<String, Object> pageQuery(int page, int size, Long roleId, String conversationId,
String intent, String startTime, String endTime, String keyword, String intent, String startTime, String endTime, String keyword,
String errorType) {
String errorType, String sortField, String sortOrder) {
if (page < 1) page = 1; if (page < 1) page = 1;
if (page > 10000) page = 10000; if (page > 10000) page = 10000;
if (size < 1 || size > 100) size = 20; if (size < 1 || size > 100) size = 20;
@ -80,7 +92,17 @@ public class LlmCallTraceService {
QueryWrapper<LlmCallTrace> listWrapper = buildWhere( QueryWrapper<LlmCallTrace> listWrapper = buildWhere(
new QueryWrapper<>(), roleId, conversationId, intent, startTime, endTime, keyword, errorType); new QueryWrapper<>(), roleId, conversationId, intent, startTime, endTime, keyword, errorType);
listWrapper.select(LlmCallTrace.class, field -> !BIG_TEXT_FIELDS.contains(field.getColumn())); listWrapper.select(LlmCallTrace.class, field -> !BIG_TEXT_FIELDS.contains(field.getColumn()));
// 排序:未指定时保持 create_time + id 双列默认,指定时按白名单单列
String sortColumn = sortField != null ? LLM_TRACE_SORT_COLUMNS.get(sortField) : null;
if (sortColumn != null) {
if (SortUtils.isAscending(sortOrder)) {
listWrapper.orderByAsc(sortColumn);
} else {
listWrapper.orderByDesc(sortColumn);
}
} else {
listWrapper.orderByDesc("create_time").orderByDesc("id"); listWrapper.orderByDesc("create_time").orderByDesc("id");
}
listWrapper.last("LIMIT " + size + " OFFSET " + ((page - 1L) * size)); listWrapper.last("LIMIT " + size + " OFFSET " + ((page - 1L) * size));
List<LlmCallTrace> records = llmCallTraceMapper.selectList(listWrapper); List<LlmCallTrace> records = llmCallTraceMapper.selectList(listWrapper);

21
src/main/java/com/wok/supportbot/service/McpServerConfigService.java

@ -1,7 +1,9 @@
package com.wok.supportbot.service; package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.wok.supportbot.common.SortUtils;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.dao.McpServerConfigMapper; import com.wok.supportbot.dao.McpServerConfigMapper;
import com.wok.supportbot.entity.McpServerConfig; import com.wok.supportbot.entity.McpServerConfig;
@ -30,6 +32,14 @@ public class McpServerConfigService {
/** 复用的 JSON 序列化器,避免每次持久化都新建实例 */ /** 复用的 JSON 序列化器,避免每次持久化都新建实例 */
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/** MCP Server 列表排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 */
private static final Map<String, String> MCP_SORT_COLUMNS = Map.of(
"name", "name",
"transport_type", "transport_type",
"is_active", "is_active",
"description", "description",
"create_time", "create_time");
// ==================== 分页列表 ==================== // ==================== 分页列表 ====================
/** /**
@ -39,15 +49,20 @@ public class McpServerConfigService {
* @param size 每页大小 * @param size 每页大小
* @return 分页结果(records/total/page/size/pages) * @return 分页结果(records/total/page/size/pages)
*/ */
public Map<String, Object> listConfigs(int page, int size) {
public Map<String, Object> listConfigs(int page, int size, String sortField, String sortOrder) {
// 查询总数 // 查询总数
LambdaQueryWrapper<McpServerConfig> countWrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<McpServerConfig> countWrapper = new LambdaQueryWrapper<>();
Long total = mcpServerConfigMapper.selectCount(countWrapper); Long total = mcpServerConfigMapper.selectCount(countWrapper);
if (total == null) total = 0L; if (total == null) total = 0L;
// 查询列表(带排序和分页) // 查询列表(带排序和分页)
LambdaQueryWrapper<McpServerConfig> listWrapper = new LambdaQueryWrapper<>();
listWrapper.orderByDesc(McpServerConfig::getCreateTime);
QueryWrapper<McpServerConfig> listWrapper = new QueryWrapper<>();
String sortColumn = SortUtils.resolveColumn(sortField, MCP_SORT_COLUMNS, "create_time");
if (SortUtils.isAscending(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<McpServerConfig> records = mcpServerConfigMapper.selectList(listWrapper); List<McpServerConfig> records = mcpServerConfigMapper.selectList(listWrapper);

22
src/main/java/com/wok/supportbot/service/MessageFeedbackService.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service; package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.dao.MessageFeedbackMapper; import com.wok.supportbot.dao.MessageFeedbackMapper;
import com.wok.supportbot.entity.MessageFeedback; import com.wok.supportbot.entity.MessageFeedback;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -27,6 +28,13 @@ public class MessageFeedbackService {
@Autowired(required = false) @Autowired(required = false)
private WebhookService webhookService; private WebhookService webhookService;
/** 反馈会话列表排序字段白名单(前端 colKey -> 内层查询列别名),防 SQL 注入 */
private static final Map<String, String> FEEDBACK_SORT_COLUMNS = Map.of(
"feedbackType", "feedback_type",
"reasonCategory", "reason_category",
"processed", "processed",
"feedbackTime", "feedback_time");
/** /**
* 按反馈条件筛选会话列表(供反馈运营看板) * 按反馈条件筛选会话列表(供反馈运营看板)
* *
@ -41,7 +49,8 @@ public class MessageFeedbackService {
*/ */
public Map<String, Object> listConversationsByFeedback(int page, int size, public Map<String, Object> listConversationsByFeedback(int page, int size,
String feedbackType, String reasonCategory, String feedbackType, String reasonCategory,
Boolean processed, String startDate, String endDate) {
Boolean processed, String startDate, String endDate,
String sortField, String sortOrder) {
StringBuilder where = new StringBuilder("WHERE mf.is_delete = false "); StringBuilder where = new StringBuilder("WHERE mf.is_delete = false ");
List<Object> params = new ArrayList<>(); List<Object> params = new ArrayList<>();
@ -70,8 +79,15 @@ public class MessageFeedbackService {
String countSql = "SELECT COUNT(DISTINCT mf.conversation_id) FROM message_feedback mf " + where; String countSql = "SELECT COUNT(DISTINCT mf.conversation_id) FROM message_feedback mf " + where;
Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray()); Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray());
// 排序字段白名单解析(防 SQL 注入),默认按反馈时间倒序。
// DISTINCT ON 要求 ORDER BY 以 conversation_id 开头,故将原查询包成子查询,
// 外层按白名单列排序,避免破坏去重语义。
String sortColumn = SortUtils.resolveColumn(sortField, FEEDBACK_SORT_COLUMNS, "feedback_time");
String orderBy = sortColumn + (SortUtils.isAscending(sortOrder) ? " ASC" : " DESC");
// 查询会话摘要:按最后反馈时间倒序,附带最后一条反馈信息 // 查询会话摘要:按最后反馈时间倒序,附带最后一条反馈信息
String querySql = """ String querySql = """
SELECT * FROM (
SELECT DISTINCT ON (mf.conversation_id) SELECT DISTINCT ON (mf.conversation_id)
mf.id::TEXT AS feedback_id, mf.id::TEXT AS feedback_id,
mf.conversation_id AS conversation_id, mf.conversation_id AS conversation_id,
@ -98,8 +114,8 @@ public class MessageFeedbackService {
AND cm_assistant.metadata ->> 'msgId' = mf.message_id AND cm_assistant.metadata ->> 'msgId' = mf.message_id
""" + where + "\n" + """ """ + where + "\n" + """
ORDER BY mf.conversation_id, mf.create_time DESC ORDER BY mf.conversation_id, mf.create_time DESC
LIMIT ? OFFSET ?
""";
) t
""" + " ORDER BY " + orderBy + " LIMIT ? OFFSET ?";
params.add(size); params.add(size);
params.add((page - 1) * size); params.add((page - 1) * size);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(querySql, params.toArray()); List<Map<String, Object>> rows = jdbcTemplate.queryForList(querySql, params.toArray());

19
src/main/java/com/wok/supportbot/service/SensitiveWordService.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service; package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.dao.SensitiveWordMapper; import com.wok.supportbot.dao.SensitiveWordMapper;
import com.wok.supportbot.entity.SensitiveWord; import com.wok.supportbot.entity.SensitiveWord;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -22,6 +23,15 @@ public class SensitiveWordService {
@Autowired @Autowired
private SensitiveWordMapper sensitiveWordMapper; private SensitiveWordMapper sensitiveWordMapper;
/** 敏感词列表排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 */
private static final Map<String, String> SENSITIVE_WORD_SORT_COLUMNS = Map.of(
"word", "word",
"category", "category",
"level", "level",
"isActive", "is_active",
"updateTime", "update_time",
"createTime", "create_time");
@Autowired @Autowired
private ContentSafetyService contentSafetyService; private ContentSafetyService contentSafetyService;
@ -35,7 +45,7 @@ public class SensitiveWordService {
* @param level 级别筛选(1=警告,2=拦截),可为空 * @param level 级别筛选(1=警告,2=拦截),可为空
* @return 包含 records/total/page/size/pages 的结果 * @return 包含 records/total/page/size/pages 的结果
*/ */
public Map<String, Object> list(int page, int size, String keyword, String category, Integer level) {
public Map<String, Object> list(int page, int size, String keyword, String category, Integer level, String sortField, String sortOrder) {
// 构建计数条件 // 构建计数条件
QueryWrapper<SensitiveWord> countWrapper = new QueryWrapper<>(); QueryWrapper<SensitiveWord> countWrapper = new QueryWrapper<>();
if (keyword != null && !keyword.isBlank()) { if (keyword != null && !keyword.isBlank()) {
@ -60,7 +70,12 @@ public class SensitiveWordService {
if (level != null) { if (level != null) {
listWrapper.eq("level", level); listWrapper.eq("level", level);
} }
listWrapper.orderByDesc("create_time");
String sortColumn = SortUtils.resolveColumn(sortField, SENSITIVE_WORD_SORT_COLUMNS, "create_time");
if (SortUtils.isAscending(sortOrder)) {
listWrapper.orderByAsc(sortColumn);
} else {
listWrapper.orderByDesc(sortColumn);
}
listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size); listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size);
List<SensitiveWord> records = sensitiveWordMapper.selectList(listWrapper); List<SensitiveWord> records = sensitiveWordMapper.selectList(listWrapper);

19
src/main/java/com/wok/supportbot/service/SysUserService.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service; package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.dao.SysRoleMapper; import com.wok.supportbot.dao.SysRoleMapper;
import com.wok.supportbot.dao.SysUserMapper; import com.wok.supportbot.dao.SysUserMapper;
import com.wok.supportbot.dao.SysUserRoleMapper; import com.wok.supportbot.dao.SysUserRoleMapper;
@ -37,6 +38,15 @@ public class SysUserService implements UserDetailsService {
private final PasswordValidator passwordValidator; private final PasswordValidator passwordValidator;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
/** 用户列表排序字段白名单(前端 colKey -> SQL 列名),防 SQL 注入 */
private static final Map<String, String> USER_SORT_COLUMNS = Map.of(
"username", "u.username",
"nickname", "u.nickname",
"email", "u.email",
"enabled", "u.enabled",
"last_login_time", "u.last_login_time",
"create_time", "u.create_time");
public SysUserService(SysUserMapper userMapper, SysRoleMapper roleMapper, public SysUserService(SysUserMapper userMapper, SysRoleMapper roleMapper,
SysUserRoleMapper userRoleMapper, PasswordEncoder passwordEncoder, SysUserRoleMapper userRoleMapper, PasswordEncoder passwordEncoder,
PasswordValidator passwordValidator, JdbcTemplate jdbcTemplate) { PasswordValidator passwordValidator, JdbcTemplate jdbcTemplate) {
@ -108,7 +118,7 @@ public class SysUserService implements UserDetailsService {
/** /**
* 分页查询用户列表 * 分页查询用户列表
*/ */
public Map<String, Object> listUsers(int page, int size, String keyword, Boolean enabled) {
public Map<String, Object> listUsers(int page, int size, String keyword, Boolean enabled, String sortField, String sortOrder) {
StringBuilder where = new StringBuilder("WHERE u.is_delete = FALSE"); StringBuilder where = new StringBuilder("WHERE u.is_delete = FALSE");
List<Object> params = new ArrayList<>(); List<Object> params = new ArrayList<>();
@ -130,6 +140,9 @@ public class SysUserService implements UserDetailsService {
// 注意:id 必须 CAST 为 TEXT,否则 Jackson 将 BIGINT 序列化为 JSON number, // 注意:id 必须 CAST 为 TEXT,否则 Jackson 将 BIGINT 序列化为 JSON number,
// 前端 JS 解析大雪花 ID 时会丢失精度(超过 Number.MAX_SAFE_INTEGER) // 前端 JS 解析大雪花 ID 时会丢失精度(超过 Number.MAX_SAFE_INTEGER)
int offset = (page - 1) * size; int offset = (page - 1) * size;
// 排序字段白名单解析(防 SQL 注入),默认按创建时间倒序
String sortColumn = SortUtils.resolveColumn(sortField, USER_SORT_COLUMNS, "u.create_time");
String orderBy = sortColumn + (SortUtils.isAscending(sortOrder) ? " ASC" : " DESC");
String dataSql = """ String dataSql = """
SELECT u.id::TEXT AS id, u.username, u.nickname, u.email, u.phone, u.enabled, SELECT u.id::TEXT AS id, u.username, u.nickname, u.email, u.phone, u.enabled,
u.last_login_time, u.create_time, u.last_login_time, u.create_time,
@ -140,9 +153,9 @@ public class SysUserService implements UserDetailsService {
LEFT JOIN sys_role r ON r.id = ur.role_id AND r.is_delete = FALSE LEFT JOIN sys_role r ON r.id = ur.role_id AND r.is_delete = FALSE
%s %s
GROUP BY u.id GROUP BY u.id
ORDER BY u.create_time DESC
ORDER BY %s
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
""".formatted(where);
""".formatted(where, orderBy);
List<Object> dataParams = new ArrayList<>(params); List<Object> dataParams = new ArrayList<>(params);
dataParams.add(size); dataParams.add(size);
dataParams.add(offset); dataParams.add(offset);

17
src/main/java/com/wok/supportbot/service/WebhookService.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service; package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.dao.WebhookConfigMapper; import com.wok.supportbot.dao.WebhookConfigMapper;
import com.wok.supportbot.entity.WebhookConfig; import com.wok.supportbot.entity.WebhookConfig;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -22,6 +23,13 @@ public class WebhookService {
@Autowired @Autowired
private WebhookConfigMapper webhookConfigMapper; private WebhookConfigMapper webhookConfigMapper;
/** Webhook 列表排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 */
private static final Map<String, String> WEBHOOK_SORT_COLUMNS = Map.of(
"name", "name",
"url", "url",
"enabled", "enabled",
"createTime", "create_time");
/** /**
* 分页查询 Webhook 列表 * 分页查询 Webhook 列表
* *
@ -29,12 +37,17 @@ public class WebhookService {
* @param size 每页条数 * @param size 每页条数
* @return 包含 records/total/page/size/pages 的结果 * @return 包含 records/total/page/size/pages 的结果
*/ */
public Map<String, Object> listWebhooks(int page, int size) {
public Map<String, Object> listWebhooks(int page, int size, String sortField, String sortOrder) {
QueryWrapper<WebhookConfig> countWrapper = new QueryWrapper<>(); QueryWrapper<WebhookConfig> countWrapper = new QueryWrapper<>();
Long total = webhookConfigMapper.selectCount(countWrapper); Long total = webhookConfigMapper.selectCount(countWrapper);
QueryWrapper<WebhookConfig> listWrapper = new QueryWrapper<>(); QueryWrapper<WebhookConfig> listWrapper = new QueryWrapper<>();
listWrapper.orderByDesc("create_time");
String sortColumn = SortUtils.resolveColumn(sortField, WEBHOOK_SORT_COLUMNS, "create_time");
if (SortUtils.isAscending(sortOrder)) {
listWrapper.orderByAsc(sortColumn);
} else {
listWrapper.orderByDesc(sortColumn);
}
listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size); listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size);
List<WebhookConfig> records = webhookConfigMapper.selectList(listWrapper); List<WebhookConfig> records = webhookConfigMapper.selectList(listWrapper);

3
src/main/resources/application.yml

@ -58,6 +58,9 @@ knowledge:
# 向量维度,需与 Embedding 模型输出维度一致 # 向量维度,需与 Embedding 模型输出维度一致
# 千问 text-embedding-v2: 1024 | 豆包 doubao-embedding-text-240515: 2048 | OpenAI text-embedding-3-small: 1536 # 千问 text-embedding-v2: 1024 | 豆包 doubao-embedding-text-240515: 2048 | OpenAI text-embedding-3-small: 1536
dimension: 1024 dimension: 1024
# 向量化分批大小:单批分块数(逐批入库,失败批只记录缺失区间不扩散到整篇)
# 默认 50;豆包多模态(vision)模型逐条调用较慢,若大量使用可调小(如 20)减小单批串行耗时
batch-size: 50
role: role:
# 严格隔离:true=角色未绑定知识库分类时禁止检索任何内容;false=可检索全部知识库 # 严格隔离:true=角色未绑定知识库分类时禁止检索任何内容;false=可检索全部知识库
strict-isolation: false strict-isolation: false

42
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 — 客服角色表
-- ============================================================ -- ============================================================
@ -333,6 +367,12 @@ CREATE TABLE IF NOT EXISTS vector_store (
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
-- ⚠️ 与 Spring AI 自动建表的差异(勿误判):
-- 1. Spring AI PgVectorStore 自动建表时 metadata 是 json(非 jsonb),且不含 create_time/update_time。
-- create_time/content_tsvector 由 DatabaseInitConfig 补加;update_time 仅本手动脚本含、无代码引用。
-- 2. vector_store 为物理删除(pgVectorVectorStore.delete),没有 is_delete 列。
-- 任何原生 SQL 禁止引用 is_delete / update_time,只允许 id/content/metadata/embedding/content_tsvector/create_time。
-- HNSW 向量索引(余弦距离) -- HNSW 向量索引(余弦距离)
CREATE INDEX IF NOT EXISTS idx_vector_store_embedding ON vector_store CREATE INDEX IF NOT EXISTS idx_vector_store_embedding ON vector_store
USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64); USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

12
src/main/resources/static/sdk/test.html

@ -4,12 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatbotSDK 测试面板</title> <title>ChatbotSDK 测试面板</title>
<script type="module" crossorigin src="/assets/sdk-test-BM4QMGvl.js"></script>
<link rel="modulepreload" crossorigin href="/assets/tdesign-BEIBDEDo.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-web-components-BtnmJ2z8.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-chat-CvvLoh5G.js">
<link rel="modulepreload" crossorigin href="/assets/markdown-pxFwF6hE.js">
<link rel="modulepreload" crossorigin href="/assets/chatAdapter-DJkCuBX0.js">
<script type="module" crossorigin src="/assets/sdk-test-0GWWiPsO.js"></script>
<link rel="modulepreload" crossorigin href="/assets/tdesign-Cz71X9te.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-web-components-CHbhIzrf.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-chat-DqKiBXye.js">
<link rel="modulepreload" crossorigin href="/assets/markdown-BR4ZWFtd.js">
<link rel="modulepreload" crossorigin href="/assets/chatAdapter-CFnRdoB6.js">
<link rel="stylesheet" crossorigin href="/assets/tdesign-CY0HVqZ3.css"> <link rel="stylesheet" crossorigin href="/assets/tdesign-CY0HVqZ3.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-chat-Dj1Q23QO.css"> <link rel="stylesheet" crossorigin href="/assets/tdesign-chat-Dj1Q23QO.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-web-components-B-ycfzW_.css"> <link rel="stylesheet" crossorigin href="/assets/tdesign-web-components-B-ycfzW_.css">

Loading…
Cancel
Save