Browse Source

Merge branch 'refs/heads/Spring-AI-1.1.2'

master
wanghanlin 2 days ago
parent
commit
6c2e30028c
  1. 116
      CLAUDE.md
  2. 2
      DEPLOY.md
  3. 40
      README.md
  4. 70
      client/src/api.ts
  5. 132
      client/src/chat.ts
  6. 1
      client/src/config.ts
  7. 28
      client/src/dom.ts
  8. 2
      client/src/i18n.ts
  9. 1
      client/src/index.ts
  10. 20
      client/src/types.ts
  11. 10
      frontend/UI-DEV-GUIDE.md
  12. 5
      frontend/components.d.ts
  13. 39
      frontend/src/App.vue
  14. 7
      frontend/src/api/api-key.ts
  15. 16
      frontend/src/api/auth.ts
  16. 4
      frontend/src/api/conversation.ts
  17. 19
      frontend/src/api/document.ts
  18. 4
      frontend/src/api/faq.ts
  19. 17
      frontend/src/api/folder.ts
  20. 4
      frontend/src/api/llm-trace.ts
  21. 9
      frontend/src/api/mcp-server.ts
  22. 4
      frontend/src/api/model-config.ts
  23. 74
      frontend/src/api/request.ts
  24. 8
      frontend/src/api/sensitive-word.ts
  25. 39
      frontend/src/api/upload.ts
  26. 4
      frontend/src/api/user.ts
  27. 7
      frontend/src/api/webhook.ts
  28. 40
      frontend/src/components/BaseTable.vue
  29. 4
      frontend/src/layouts/MainLayout.vue
  30. 91
      frontend/src/layouts/Topbar.vue
  31. 1
      frontend/src/router/index.ts
  32. 18
      frontend/src/stores/auth.ts
  33. 14
      frontend/src/stores/document.ts
  34. 7
      frontend/src/styles/shared.css
  35. 21
      frontend/src/types/models.ts
  36. 4
      frontend/src/utils/sse.ts
  37. 21
      frontend/src/utils/token.ts
  38. 11
      frontend/src/views/AccountManager.vue
  39. 14
      frontend/src/views/ApiKeyManager.vue
  40. 12
      frontend/src/views/AuditLogManager.vue
  41. 25
      frontend/src/views/CategoryManager.vue
  42. 16
      frontend/src/views/ConversationManager.vue
  43. 6
      frontend/src/views/DashboardPanel.vue
  44. 11
      frontend/src/views/DocDetail.vue
  45. 496
      frontend/src/views/DocList.vue
  46. 262
      frontend/src/views/DocUpload.vue
  47. 18
      frontend/src/views/FaqManager.vue
  48. 18
      frontend/src/views/FeedbackOps.vue
  49. 8
      frontend/src/views/LoginPage.vue
  50. 44
      frontend/src/views/McpServerManager.vue
  51. 70
      frontend/src/views/ModelConfigManager.vue
  52. 42
      frontend/src/views/PipelineFlow.vue
  53. 52
      frontend/src/views/PromptTracePanel.vue
  54. 20
      frontend/src/views/SensitiveWordManager.vue
  55. 19
      frontend/src/views/UserManager.vue
  56. 12
      frontend/src/views/WebhookManager.vue
  57. 65
      pom.xml
  58. 5
      src/main/java/com/wok/supportbot/SupportBotApplication.java
  59. 43
      src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java
  60. 13
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  61. 32
      src/main/java/com/wok/supportbot/app/ChatPipeline.java
  62. 2
      src/main/java/com/wok/supportbot/app/ChatRequest.java
  63. 5
      src/main/java/com/wok/supportbot/app/ChatResult.java
  64. 31
      src/main/java/com/wok/supportbot/app/SuggestionGenerator.java
  65. 285
      src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java
  66. 207
      src/main/java/com/wok/supportbot/auth/AuthController.java
  67. 85
      src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java
  68. 40
      src/main/java/com/wok/supportbot/common/SortUtils.java
  69. 36
      src/main/java/com/wok/supportbot/config/AsyncExecutorConfig.java
  70. 8
      src/main/java/com/wok/supportbot/config/ChatModelFactory.java
  71. 31
      src/main/java/com/wok/supportbot/config/CorsConfig.java
  72. 158
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  73. 30
      src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java
  74. 16
      src/main/java/com/wok/supportbot/config/VolcengineMultimodalEmbeddingModel.java
  75. 6
      src/main/java/com/wok/supportbot/controller/AiModelConfigController.java
  76. 6
      src/main/java/com/wok/supportbot/controller/ApiKeyController.java
  77. 6
      src/main/java/com/wok/supportbot/controller/AttachmentController.java
  78. 12
      src/main/java/com/wok/supportbot/controller/AuthController.java
  79. 6
      src/main/java/com/wok/supportbot/controller/ConversationController.java
  80. 143
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  81. 6
      src/main/java/com/wok/supportbot/controller/FaqController.java
  82. 136
      src/main/java/com/wok/supportbot/controller/FolderController.java
  83. 6
      src/main/java/com/wok/supportbot/controller/LlmCallTraceController.java
  84. 31
      src/main/java/com/wok/supportbot/controller/McpServerConfigController.java
  85. 6
      src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java
  86. 22
      src/main/java/com/wok/supportbot/controller/SensitiveWordController.java
  87. 38
      src/main/java/com/wok/supportbot/controller/SysUserController.java
  88. 6
      src/main/java/com/wok/supportbot/controller/WebhookController.java
  89. 83
      src/main/java/com/wok/supportbot/converter/MessageConverter.java
  90. 12
      src/main/java/com/wok/supportbot/dao/KnowledgeFolderMapper.java
  91. 73
      src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java
  92. 30
      src/main/java/com/wok/supportbot/document/transform/MyKeywordEnricher.java
  93. 67
      src/main/java/com/wok/supportbot/document/transform/MyTokenTextSplitter.java
  94. 252
      src/main/java/com/wok/supportbot/document/transform/OverlapTokenTextSplitter.java
  95. 63
      src/main/java/com/wok/supportbot/entity/FolderNode.java
  96. 15
      src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java
  97. 80
      src/main/java/com/wok/supportbot/entity/KnowledgeFolder.java
  98. 4
      src/main/java/com/wok/supportbot/entity/SysUser.java
  99. 5
      src/main/java/com/wok/supportbot/handler/MyMetaObjectHandler.java
  100. 36
      src/main/java/com/wok/supportbot/mcp/McpToolCallback.java

116
CLAUDE.md

@ -33,8 +33,8 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
## 核心架构决策
### 主启动类排除了 PgVectorStoreAutoConfiguration
`SupportBotApplication.java``@SpringBootApplication(exclude = PgVectorStoreAutoConfiguration.class)`,因为项目在 `PgVectorStoreConfig` 中手动配置 PgVectorStore Bean(标记 `@Primary`),不使用自动配置。另有一个 `InMemoryVectorStoreConfig` 作为开发备选。
### 手动配置 PgVectorStore(未引入自动配置)
`PgVectorStoreConfig` 手动配置 PgVectorStore Bean(标记 `@Primary`),`SupportBotApplication` 是裸 `@SpringBootApplication`、无任何 exclude —— 因为项目依赖的是**非 starter** 的 `spring-ai-pgvector-store`(只有实现类,不含 `spring-ai-autoconfigure-vector-store-pgvector`),classpath 上本就没有 `PgVectorStoreAutoConfiguration`。另有一个 `InMemoryVectorStoreConfig` 作为开发备选。
### Spring AI 集成模式
- **ChatClient Builder**: 所有对话通过 `ChatClient.builder(chatModelFactory.getChatModel("CHAT"))` 构建,ChatModel 由 `ChatModelFactory` 按 DB 活跃配置动态创建
@ -43,7 +43,7 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- **SSE 流式**: 仅保留 `Flux<String>` 形态;废弃的 `Flux<ServerSentEvent>``SseEmitter` 已移除
### ChatMemory 持久化
当前使用 `DatabaseChatMemory`(PostgreSQL 持久化),`FileBasedChatMemory`(Kryo 序列化)已注释掉
当前使用 `DatabaseChatMemory`(PostgreSQL 持久化),无文件型 ChatMemory(早期的 `FileBasedChatMemory` 已删除,Kryo 依赖一并移除)
### 统一对话管道(重构后)
对话管道由 `ChatPipeline`(编排层)+ `RagPipeline`(RAG 检索层)+ `AssistantApp`(执行层)组成:
@ -67,7 +67,21 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- **Open API**: `OpenApiController` 已接入 `ChatPipeline`,补齐角色/RAG/FAQ/MCP/分类隔离能力
### 文档处理管道
`DocumentService.uploadDocument()` 统一流程:文档提取 → `MyTokenTextSplitter` 分块 → `MyKeywordEnricher` AI 关键词提取 → `pgVectorVectorStore.add()` 向量化存储。每个分块的 metadata 中注入 `documentId`、`chunkIndex`、`sourceName`、`title` 以关联 `knowledge_document` 表。
`DocumentService.uploadDocument()` 统一流程:文档提取(官方 `org.springframework.ai.reader.tika.TikaDocumentReader` / `MarkdownDocumentReader` / `JsonReader`)→ `OverlapTokenTextSplitter` 分块 → 为每块写 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` 全库无检索消费点)。
**`content` 列存全文 + 重新处理的数据源规则**(`DocumentService`,修复了「重跑丢内容」缺陷):
| 项 | 规则 |
|---|---|
| 存储 | `knowledge_document.content` 存**原文全文**(不再截断),并写 `extra_config.contentComplete = true` 作为完整性标记 |
| 序列化 | 实体 `content``@JsonIgnore`,**任何接口都不返回全文**(否则列表接口每行都带整篇正文)。仅文档详情接口(`DocumentController.toDetailMap`)返回 2000 字预览(键名仍是 `content`)+ `contentTruncated` 布尔标志 |
| 重新处理的数据源 | ① 有 `contentComplete` 标记 → 用库内全文(语义最忠实,如 JSON 的 fields/pointer 模式)② 历史遗留文档(无标记,content 是 2000 字截断预览)→ 按 `fileType` 从原始文件重解析,成功后**回填全文并打标记**(此后不再依赖文件)③ 两者都不可用 → **明确报错拒绝** |
**踩坑记录**:`reprocessDocument` 原先直接 `simpleStringDocumentReader.read(doc.getContent())`,而 `content` 是 2000 字截断预览,且 `DocumentProcessingService``cleanBeforeFirstAttempt=true` 运行(**先删光旧向量**)—— 对超过 2000 字符的文档重跑会永久丢失其余内容。修复前**不要**对存量文档执行 `POST /document/batch/reprocess`。判断遗留文档:`extra_config->>'contentComplete' IS NULL`;其中 `length(content) = 2000` 的才是真正被截断的。
**JSON 解析模式的已知降级**:JSON 的 basic/fields/pointer 三种模式上传时未持久化,历史 JSON 文档从文件重解析只能按 basic 还原(不丢数据,仅抽取口径可能变化,日志有 WARN)。新文档走库内全文分支,语义不变。
## 关键配置
@ -76,9 +90,18 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- **模型名称、温度、最大 Token 等参数已全部迁移到前端「AI 大模型配置管理」页面**,通过 `ai_model_config` 表管理,不再在 yml 中配置(yml 仅保留 `api-key`
- MyBatis Plus 逻辑删除字段: `isDelete`,主键策略: `assign_id`(雪花算法)
- **雪花 ID 精度问题**: `KnowledgeDocument.id`、`categoryId` 和 `KnowledgeCategory.id`、`parentId` 已添加 `@JsonSerialize(using = ToStringSerializer.class)`,序列化为字符串避免前端 JS 精度丢失。新增 Long ID 字段时务必加上此注解
- **JWT 时间配置统一用 Duration 可读格式**: `jwt.expiration` / `jwt.refresh-expiration` / `jwt.sdk-expiration` 均绑定为 `java.time.Duration`(`@Value` 直绑),值写成 `15m` / `8h` / `900s` 等可读形式,**纯数字仍按毫秒解析**(向后兼容旧配置)。项目同类先例:`storage.sftp.connect-timeout: 10s`(`StorageProperties`)。新增时间类配置项时照此办理,不要写裸毫秒。
- **管理后台会话有效期**: refresh token 有效期由 `jwt.refresh-expiration` **唯一**驱动(`application.yml`,当前 `8h`)。该配置**全环境统一生效**,不在 `application-dev/prod.yml` 中覆盖;`JwtTokenProvider` 构造器上的 `:8h` 仅为代码级兜底。**refresh Cookie 的 Max-Age 必须经 `jwtTokenProvider.getRefreshExpirationSeconds()` 取值,禁止硬编码**(`AuthController` 登录与刷新两处),否则「改配置不改 Cookie」会造成有效期漂移。
- 语义为**滚动续期**:access token 15 分钟过期后前端静默调 `/auth/refresh`,服务端重签并重置窗口。因此**页面持续活跃的用户不会掉线**,只有闲置超过该时长(含关闭页面超过该时长后重开,refresh Cookie 已过期)才需重新登录。
- 相关前端链路:`App.vue onMounted` 启动自检(`/auth/me` → 失败则 `tryRefreshToken`**再校验一次 `/auth/me`**,仍失败即登出)+ `api/request.ts` 的 401 自动刷新重试(single-flight + 重试上限 `_retry`)。
- **SDK Token 有效期**: `jwt.sdk-expiration`(当前 `2h`)作为 SDK 换 Token 接口(`POST /open-api/auth/token`,SDK 版 `controller/AuthController`)**未指定 ttl 时的默认值**,由 `SdkJwtTokenProvider.getDefaultExpirationMillis()` 提供。有效期边界 `[5min, 24h]` 只在 `SdkJwtTokenProvider``MIN_EXPIRATION` / `MAX_EXPIRATION` 两处常量定义,控制器通过 `clampExpirationMillis()` 复用,**不得在控制器内重复写毫秒魔数**。注意 SDK 对外的 `ttl` 请求参数与 `expiresIn` 响应字段单位是**秒**(见 `SDK-INTEGRATION.md`),与内部毫秒配置是两套单位,勿混淆。
- PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象 `'{}'`,非数组 `'[]'`
- **向量维度**: 由 `knowledge.vector.dimension` 配置(默认 1024)。修改后需执行 `DROP TABLE IF EXISTS vector_store CASCADE` 重建向量表,并重新上传知识库文档。距离类型: COSINE_DISTANCE,索引: HNSW
- **分块配置**: `knowledge.chunk.*` 配置项(`ChunkConfig`),默认 chunkSize=200, overlap=100, minChunkSizeChars=10, maxNumChunks=5000, keepSeparator=true
- **分块器 `OverlapTokenTextSplitter`**: Spring AI 的 `TokenTextSplitter` **不支持 overlap**(构造器与 Builder 均无该形参,社区 PR #4054 不向 1.x 回迁)。项目继承标准 `TextSplitter` 基类自研了 `OverlapTokenTextSplitter`(`document/transform/`),复刻 `TokenTextSplitter` 全部切分语义,仅把前进步长由 `chunkSize` 改为 `chunkSize - overlap`,并用 jtokkit(CL100K_BASE,与上游同库)做 token 编码。
- **前进步长必须有下限(`minAdvance()`)**: 标点截断会缩短本块消耗的 token 数,而步长 = `消耗量 - overlap`;若截断点靠前,步长会被压到 1 个 token,分块数成倍膨胀(实测 `chunkSize=60/overlap=30` 时 349 块 vs 修复后 44 块)。因此**仅当截断后仍能前进至少 `(chunkSize - overlap) / 2` 个 token 时才采用该截断**,否则宁可切断句子。`overlap=0` 时该下限取 1,与标准 `TokenTextSplitter` 行为**逐块一致**(已有对比验证)。
- **历史缺陷已修复**: 旧 `MyTokenTextSplitter` 因形参错位,把 `overlap` 传进了 `minChunkSizeChars` 位,导致 `knowledge.chunk.overlap` 从未生效。修复后重叠真正生效,**分块边界与块数会变化**,存量文档需重新分块+向量化(`POST /document/batch/reprocess`)。
- `minChunkLengthToEmbed` 固定为 10(`DocumentProcessingService.MIN_CHUNK_LENGTH_TO_EMBED`,ChunkConfig 无对应配置项)
- **上传校验**: `ALLOWED_EXTENSIONS` 白名单 + 50MB 大小限制(`spring.servlet.multipart` 配置),前后端双重校验
- **文档去重**: `KnowledgeDocument.contentHash` 字段(SHA-256),上传时自动计算并查重
- **数据库自动初始化**: `DatabaseInitConfig` 在启动时检查并创建 `knowledge_category`/`knowledge_document`/`ai_model_config` 等表,对已存在的 `knowledge_document` 表会自动补加 `content_hash` 列。注意 `knowledge-base.sql` 脚本为早期版本,缺少此列,实际以 `DatabaseInitConfig` 为准
@ -92,14 +115,19 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- **ChatModel 运行时切换**: 通过 `ChatModelFactory` 按 DB 活跃配置动态创建/缓存 ChatModel(包括 DashScope,不再复用 yml 自动配置的 Bean),配置变更时立即生效(无需重启)。**OpenAI 兼容路径使用自定义 `completionsPath`**(与 EmbeddingModelFactory 的 embeddingsPath 对应),baseUrl 已含版本段的厂商(moonshot `/v1`、volcengine `/api/v3`、zhipu `/api/paas/v4`)设为 `/chat/completions`,其余使用默认 `/v1/chat/completions`
- **EmbeddingModel 运行时切换**: 通过 `EmbeddingModelFactory` + `DynamicEmbeddingModel` 代理,按 DB 活跃配置动态创建/缓存 EmbeddingModel,`PgVectorStoreConfig` 和 `InMemoryVectorStoreConfig` 注入 `DynamicEmbeddingModel`,向量化模型配置变更后无需重启即可生效
- **多提供商支持**: DashScope(通义千问)+ OpenAI 兼容提供商(DeepSeek / 豆包 / Kimi / 智谱 / OpenAI),ChatModel 和 EmbeddingModel 均通过对应 API 手动构建
- **缓存刷新**: 配置增删改激活时 Controller 自动调用 `ChatModelFactory.clearCache()` + `EmbeddingModelFactory.clearCache()` + `AssistantApp.clearCache()`
- **缓存刷新**: 模型配置增删改激活时 Controller 自动调用 `ChatModelFactory.clearCache()` + `EmbeddingModelFactory.clearCache()` + `AssistantApp.clearCache()`;MCP Server 增删改/启停/全量刷新时 `McpServerConfigController` 亦会调用 `AssistantApp.clearCache()`(避免继续使用旧的 MCP 工具集)
- **动态模型列表获取**: `ModelListFetcher` 通过调用各提供商的 `/v1/models` 兼容端点(DashScope 用 `/compatible-mode/v1/models`),动态获取可用模型列表。前端填入 API Key + API 地址后,点击「获取模型」即可自动填充模型名称下拉列表(`<datalist>` 支持搜索选择 + 自定义输入)
### 依赖版本
- Spring AI BOM: `1.0.1`,统一管理所有 `org.springframework.ai` 依赖版本
- `spring-ai-alibaba-starter-dashscope`: `1.0.0.4`(新版 starter,替代老版 `spring-ai-alibaba-starter` M6.1)
- `spring-ai-openai`: BOM 管理(OpenAI 兼容提供商支持)
- Spring Boot: `3.5.8`(Spring AI Alibaba 1.1.2.2 官方基线)
- Spring AI BOM: `1.1.2`(properties 中的 `spring-ai.version`),统一管理所有 `org.springframework.ai` 依赖版本
- `spring-ai-alibaba-starter-dashscope`: `1.1.2.2`。**必须显式写版本号** —— `spring-ai-alibaba-bom` 并不管理该模块(它只管理 agentscope / graph / studio 等 8 个模块),因此项目**不导入** SAA BOM
- `spring-ai-openai`: BOM 管理(OpenAI 兼容提供商支持)。**刻意使用非 starter 坐标**:项目手动构建 ChatModel/EmbeddingModel,用 starter 会额外引入 `-model-openai` / `-chat-client` / `-chat-memory` 三个自动配置,可能生成与手写工厂、`DatabaseChatMemory` 冲突的 Bean。`spring-ai-pgvector-store` 同理
- `spring-ai-alibaba-starter` (M6.1) 已移除,不再使用
- **MCP SDK 必须锁 0.18.3**(`pom.xml` 中排除传递依赖 + 显式声明):Spring AI 1.1.2 的 `spring-ai-mcp` 仍锁 MCP SDK **0.17.0**,而 0.17.0 的 JsonMapper 包名是 `io.modelcontextprotocol.json.jackson`(无 `jackson2`),项目 `McpClientManager` 用的是 0.18.0 起才有的 `...json.jackson2.JacksonMcpJsonMapper`。`spring-ai-mcp` 只引用 `McpSyncClient/McpAsyncClient/McpClient/McpTransportContext/McpSchema/Assert`,这些类 0.18.3 均存在,故覆盖安全
- `com.github.victools:jsonschema-generator`: 不再显式声明,由 `spring-ai-model:1.1.2` 传递引入(4.38.0,结构化输出 `BeanOutputConverter` 依赖它)
- `com.knuddels:jtokkit:1.1.0`: 显式声明,`OverlapTokenTextSplitter` 直接使用
- `com.esotericsoftware:kryo``org.springframework.security:spring-security-oauth2-client` 均已移除(唯一使用者/唯一用途已消失;后者曾是 Spring AI 1.0.x `ToolCallingAutoConfiguration` 的 ClassNotFound workaround,1.1.2 已无该耦合)
### EmbeddingModel 架构
- **EmbeddingConfigFixer**:`ApplicationListener<ApplicationReadyEvent>`,启动时检查 EMBEDDING 配置合理性、**校验 EmbeddingModel 实际维度与配置维度是否一致**,不一致时 WARN 告警并给出修复步骤。**不再强制修正非 DashScope 配置**,尊重用户在 DB 中配置的提供商和模型
@ -148,6 +176,20 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
**反面案例**: `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 字段,必须保证前端收到的是**字符串**而非数字,防止 JS 超过 `Number.MAX_SAFE_INTEGER`(2^53)精度丢失。
@ -162,6 +204,41 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
### 后端:敏感字段脱敏
**规则**: 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」。
### 前端:弹窗实现统一模式
项目中存在两种弹窗模式,**不可混用**:
@ -201,6 +278,21 @@ catch (e) { toast('操作失败', 'error') }
**反面案例**: commit `527d9e7` 创建的流程图展示了 RAG 子图中 `VECTOR → RRF | KEYWORD → RRF | HYBRID → RRF → Reranker` 的三模式检索流程,但实际 `RagPipeline.similaritySearch()` 仅做纯向量检索,`HybridSearchService`/`RrfFusion`/`RerankerService` 尚未接入主对话流程,导致图表与代码事实不符。
### 后端:CORS 双轨制(SDK 接口开放 / 管理接口白名单)
**规则**: CORS 配置分两套,**不可整体收紧为单一白名单**(系统可用性第一):
| 路径 | CORS 策略 | 原因 |
|------|----------|------|
| SDK 第三方接入接口:`/ai/**`、`/category/tree`、`/category/list`、`/feedback`、`/attachment/upload` | `allowedOriginPatterns("*")` 开放跨域 | 第三方域名动态未知,用 Bearer Token 鉴权,不依赖来源白名单 |
| 管理后台接口:其余 `/**` | `allowed-origins` 白名单 | 用 httpOnly refresh cookie,需白名单防跨域 CSRF |
配置有两处,**必须同步修改**:`SecurityConfig.corsConfigurationSource()`(Security 链 CorsFilter)与 `CorsConfig.addCorsMappings()`(Spring MVC 层 CorsInterceptor)。两者均按注册顺序匹配,**先注册精确的 SDK 路径,再注册兜底的 `/**`**。
**反面案例**: commit `9338fcb` 为配合 refresh token 迁移 httpOnly Cookie,把 CORS 从 `allowedOriginPatterns("*")` 整体收紧为白名单,引发两个回归:(1) 同源静态资源 `<script crossorigin>`/`<link crossorigin>` 带 `Origin` 头、白名单缺线上域名 → 首页空白 + assets 403;(2) SDK 第三方跨域、第三方域名不在白名单 → 接口 403。教训:改 CORS 必须同时考虑「同源静态资源的 crossorigin 属性」和「SDK 第三方跨域」两个场景。
**部署提醒**: 更换前端域名/端口时,必须同步把新域名加入 `application-prod.yml``app.cors.allowed-origins`,否则同源静态资源会 403。
## API 路由约定
- AI 对话: `/ai/*`(`AiController`)
@ -250,7 +342,7 @@ catch (e) { toast('操作失败', 'error') }
- **会话导出**: `ConversationService.exportConversation()` 导出的 TXT 中包含反馈信息
### 意图识别 + FAQ 精准匹配(P0-003)
- **IntentRouter**: LLM 单次调用做意图分类(FAQ/RAG/CHITCHAT),解析失败降级为 RAG
- **IntentRouter**: LLM 单次调用做意图分类(FAQ/RAG/CHITCHAT);结构化输出由标准组件 `BeanOutputConverter<IntentResult>` 生成 JSON Schema 指令并反序列化结果,解析失败/结果非法降级为 RAG
- **FaqMatchEngine**: 三级匹配策略 — 精确匹配 → 关键词匹配 → 向量语义匹配(阈值 `knowledge.faq.semantic-threshold`,默认 0.85)
- **FAQ 向量化**: 复用现有 `DynamicEmbeddingModel`,向量存入 `faq_embedding` 表,新增/修改 FAQ 时异步计算
- **similar_questions 字段**: 使用 String 类型存储 JSON 数组字符串(PostgresJsonTypeHandler 期望对象格式,故不用 typeHandler)
@ -260,7 +352,7 @@ catch (e) { toast('操作失败', 'error') }
- **SearchMode**: 枚举 VECTOR(默认)/ KEYWORD / HYBRID,向后兼容
- **HybridSearchService**: 多模式检索核心,KEYWORD 使用 PostgreSQL `tsvector` 全文检索,HYBRID 使用双路检索 + RRF 融合
- **RrfFusion**: RRF 融合算法 `score = Σ 1/(k + rank_i)`,k=60
- **RerankerService**: 支持 DashScope + OpenAI 兼容提供商,通过 `ai_model_config` 表 RERANK 类型配置,超时 3s 自动 fallback
- **RerankerService**: 支持 DashScope + OpenAI 兼容提供商,通过 `ai_model_config` 表 RERANK 类型配置;HTTP 由 `RestClient` + `JdkClientHttpRequestFactory` 显式设置 connect/read 超时(各 3s,原 `RestTemplate` 无任何超时),超时/异常自动 fallback 到 RRF 原始排序
- **vector_store 全文检索**: 新增 `content_tsvector` 列 + GIN 索引 + PostgreSQL 触发器自动维护
- **前端**: `DocSearch.js` 增加检索模式下拉选择(向量/关键词/混合),结果标注来源模式
@ -276,7 +368,7 @@ catch (e) { toast('操作失败', 'error') }
## 已知 TODO
- `DocumentService.updateDocumentMetadata()`: Spring AI 无直接更新 vector_store metadata 的 API,向量元数据同步留后续
- `DocumentService.searchDocuments()`: Spring AI 1.0.1 的 filter 支持有限,分类过滤暂未实现
- `DocumentService.searchDocuments()`: **分类过滤已实现**(`FilterExpressionBuilder` 组合 `enabled` + `categoryId` 过滤表达式,向量检索异常时回退到本地 metadata 过滤)—— 原「Spring AI filter 支持有限」的 TODO 已不成立
- `CompressionQueryRewriter`: 当前传入空历史列表
- MyBatis Plus 3.5.12 的 `mybatis-plus-spring-boot3-starter` 不含 `PaginationInnerInterceptor`,分页通过 SQL `LIMIT/OFFSET` 手动实现
- MyBatis Plus `mybatis-plus-spring-boot3-starter` 不含 `PaginationInnerInterceptor`,分页通过 SQL `LIMIT/OFFSET` 手动实现
- `PgVectorStoreConfig.dimensions(1024)` 硬编码了向量维度,切换非 1024 维的 Embedding 模型时需修改并重建 vector_store 表 → **已修复:维度由 `knowledge.vector.dimension` 配置,启动时自动检测不匹配并告警**

2
DEPLOY.md

@ -749,4 +749,4 @@ sudo DB_PASS=your_strong_password /opt/support-bot/deploy.sh
---
*本教程基于项目当前代码状态(Spring Boot 3.4.4 / Java 17 / Spring AI 1.0.1 / PGVector)编写。*
*本教程基于项目当前代码状态(Spring Boot 3.5.8 / Java 17 / Spring AI 1.1.2 / Spring AI Alibaba 1.1.2.2 / PGVector)编写。*

40
README.md

@ -2,7 +2,7 @@
> 🤖 基于 Spring AI 和阿里云通义千问的智能客服机器人
> 🧠 支持 RAG 知识库检索 · 多种对话模式 · 结构化数据提取 · 知识库管理
> 🧱 技术栈:Java 17 · Spring Boot 3.4.4 · Spring AI Alibaba · PGVector
> 🧱 技术栈:Java 17 · Spring Boot 3.5.8 · Spring AI Alibaba · PGVector
## 📌 项目简介
@ -29,16 +29,15 @@
| 技术分类 | 技术组件 | 版本 | 用途说明 |
|---------|---------|------|---------|
| **后端框架** | Spring Boot | 3.4.4 | 主框架,提供依赖注入和自动配置 |
| **AI框架** | Spring AI Alibaba | 1.0.0-M6.1 | AI集成框架,简化大模型调用 |
| **后端框架** | Spring Boot | 3.5.8 | 主框架,提供依赖注入和自动配置 |
| **AI框架** | Spring AI Alibaba | 1.1.2.2 | AI集成框架,简化大模型调用(Spring AI BOM 1.1.2) |
| **大语言模型** | 阿里云通义千问 | qwen-turbo | 对话生成和文本理解 |
| **Embedding模型** | 阿里云DashScope | text-embedding-v2 | 文本向量化(1024维) |
| **数据库** | PostgreSQL + PGVector | 12+ | 关系数据存储 + 向量存储 |
| **ORM框架** | MyBatis Plus | 3.5.12 | 数据库操作和对象映射 |
| **API文档** | Knife4j | 4.4.0 | Swagger UI增强版 |
| **工具库** | Hutool | 5.8.37 | 常用工具类集合 |
| **序列化** | Kryo | 5.6.2 | 高性能序列化框架 |
| **文档解析** | Apache Tika | 1.0.0 | 多格式文档内容提取 |
| **API文档** | Knife4j | 4.5.0 | Swagger UI增强版 |
| **工具库** | Hutool | 5.8.41 | 常用工具类集合 |
| **文档解析** | Apache Tika | 经 spring-ai-tika-document-reader | 多格式文档内容提取(使用 Spring AI 官方 `TikaDocumentReader`) |
## 🗃️ 数据库设计
@ -95,7 +94,7 @@ CREATE TABLE knowledge_document (
source_name VARCHAR(500), -- 原始文件名
file_type VARCHAR(20) NOT NULL, -- 文件类型
file_size BIGINT DEFAULT 0, -- 文件大小(字节)
content TEXT, -- 原文内容(截断预览)
content TEXT, -- 原文全文(重处理用;接口仅返回 2000 字预览)
category_id BIGINT DEFAULT 0, -- 所属分类ID
tags JSONB DEFAULT '{}', -- 标签(JSON对象)
chunk_count INTEGER DEFAULT 0, -- 分块数量
@ -162,12 +161,11 @@ src/main/java/com/wok/supportbot/
├── SupportBotApplication.java # 主启动类
├── advisor/ # AI对话增强器
│ ├── MyLoggerAdvisor.java # 日志记录顾问
│ └── ReReadingAdvisor.java # 重读机制顾问
│ └── ContentSafetyAdvisor.java # 内容安全顾问
├── app/ # 核心应用服务
│ └── AssistantApp.java # 智能客服应用
├── chatmemory/ # 聊天记忆管理
│ ├── DatabaseChatMemory.java # 数据库记忆存储
│ └── FileBasedChatMemory.java # 文件记忆存储
│ └── DatabaseChatMemory.java # 数据库记忆存储
├── config/ # 系统配置
│ ├── CorsConfig.java # 跨域请求配置
│ ├── MybatisPlusConfig.java # MyBatis Plus 配置
@ -182,14 +180,12 @@ src/main/java/com/wok/supportbot/
│ ├── KnowledgeDocumentMapper.java # 知识文档 Mapper
│ └── KnowledgeCategoryMapper.java # 知识分类 Mapper
├── document/ # 文档处理管道
│ ├── extract/ # 文档提取器
│ │ ├── TikaDocumentReader.java # Tika 多格式解析
│ │ ├── MarkdownDocumentLoader.java # Markdown 解析
│ │ ├── JsonDocumentLoader.java # JSON 解析(3种模式)
│ ├── extract/ # 文档提取器(均基于 Spring AI 官方读取器)
│ │ ├── MarkdownDocumentLoader.java # Markdown 解析(MarkdownDocumentReader)
│ │ ├── JsonDocumentLoader.java # JSON 解析(JsonReader,3种模式)
│ │ └── SimpleStringDocumentReader.java # 纯文本读取
│ └── transform/ # 文档转换器
│ ├── MyTokenTextSplitter.java # Token 分块器
│ └── MyKeywordEnricher.java # AI 关键词提取
│ └── OverlapTokenTextSplitter.java # 带重叠的 Token 分块器(继承标准 TextSplitter)
├── entity/ # 数据实体类
│ ├── ChatMessage.java # 聊天消息实体
│ ├── KnowledgeDocument.java # 知识文档实体
@ -230,15 +226,15 @@ src/main/resources/
```
上传文件/文本
[文档提取器] Tika / Markdown / JSON / 纯文本
[文档提取器] Tika(官方 TikaDocumentReader)/ Markdown / JSON / 纯文本
[Token 分块] MyTokenTextSplitter (200 token / 100 overlap)
[Token 分块] OverlapTokenTextSplitter (chunkSize=200, overlap=100)
[关键词提取] 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 聚合缺失区间)
```
## 🖥️ 前端管理页面

70
client/src/api.ts

@ -6,7 +6,7 @@
* userId accountId ID
* chatId ID /ai/sdk/conversation/list
*/
import { ResolvedConfig, ApiResponse, CategoryNode } from './types';
import { ResolvedConfig, ApiResponse, CategoryNode, ImageAttachment } from './types';
import { logger } from './logger';
import { t } from './i18n';
@ -72,13 +72,22 @@ function setIfPresent(params: URLSearchParams, key: string, value: string | numb
// ==================== 对话接口 URL 构建 ====================
/**
* URL encodeURIComponent
*/
function appendImageUrls(params: URLSearchParams, imageUrls?: string[]): void {
if (imageUrls && imageUrls.length > 0) {
params.set('imageUrls', imageUrls.map(u => encodeURIComponent(u)).join(','));
}
}
/**
* URL
* - integrateId roleId
* - userId accountId
* - chatId ID
*/
function buildChatUrl(message: string): string {
function buildChatUrl(message: string, imageUrls?: string[]): string {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig!.chatId);
@ -87,6 +96,7 @@ function buildChatUrl(message: string): string {
setIfPresent(params, 'roleId', getActiveIntegrateId());
// userId 映射为 accountId
setIfPresent(params, 'accountId', currentConfig!.userId);
appendImageUrls(params, imageUrls);
return buildUrl(`/ai/chat?${params.toString()}`);
}
@ -94,7 +104,7 @@ function buildChatUrl(message: string): string {
/**
* SSE URL
*/
function buildChatSSEUrl(message: string, categoryId?: number, useRag?: boolean): string {
function buildChatSSEUrl(message: string, categoryId?: number, useRag?: boolean, imageUrls?: string[]): string {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig!.chatId);
@ -108,6 +118,7 @@ function buildChatSSEUrl(message: string, categoryId?: number, useRag?: boolean)
params.set('enableRag', 'true');
params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE');
}
appendImageUrls(params, imageUrls);
return buildUrl(`/ai/chat/stream?${params.toString()}`);
}
@ -161,8 +172,9 @@ async function safeFetch(
Object.assign(headers, options.headers as Record<string, string>);
}
}
// 为受 SdkAuthFilter 保护的路径自动注入 Bearer Token(/ai/** 和 /feedback)
if (currentConfig?.token && (url.includes('/ai/') || url.endsWith('/feedback'))) {
// 为受 SdkAuthFilter 保护的路径自动注入 Bearer Token(/ai/**、/feedback、/attachment/upload)
if (currentConfig?.token &&
(url.includes('/ai/') || url.endsWith('/feedback') || url.includes('/attachment/upload'))) {
headers['Authorization'] = `Bearer ${currentConfig.token}`;
}
@ -223,8 +235,8 @@ function getHttpErrorMessage(status: number): string {
/**
*
*/
export async function chatRequest(message: string): Promise<string> {
const url = buildChatUrl(message);
export async function chatRequest(message: string, imageUrls?: string[]): Promise<string> {
const url = buildChatUrl(message, imageUrls);
logger.lifecycleSend(getActiveIntegrateId(), message.length);
try {
@ -257,11 +269,12 @@ export async function chatSSERequest(
onError: (error: CskError) => void,
categoryId?: number,
useRag?: boolean,
imageUrls?: string[],
signal?: AbortSignal
): Promise<void> {
const url = useRag
? buildChatSSEUrl(message, categoryId, true)
: buildChatSSEUrl(message, categoryId, false);
? buildChatSSEUrl(message, categoryId, true, imageUrls)
: buildChatSSEUrl(message, categoryId, false, imageUrls);
let totalText = '';
/**
@ -449,6 +462,43 @@ export async function chatSSERequest(
}
}
// ==================== 图片上传 ====================
/** 上传超时时间(图片体积较大,放宽到 120s) */
const UPLOAD_TIMEOUT = 120000;
/**
* /attachment/upload访 URL
*/
export async function uploadAttachment(file: File): Promise<ImageAttachment> {
const url = buildUrl('/attachment/upload');
const fd = new FormData();
fd.append('file', file);
const response = await safeFetch(url, { method: 'POST', body: fd }, UPLOAD_TIMEOUT);
if (!response.ok) {
throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
}
const json = await response.json() as ApiResponse<{
url: string;
name?: string;
mimeType?: string;
size?: number;
}>;
if (!json.success || !json.data?.url) {
throw new CskError(json.message || t('error_upload_failed'), 'upload_failed');
}
return {
name: json.data.name || file.name,
url: json.data.url,
mimeType: json.data.mimeType,
size: json.data.size,
};
}
// ==================== P1: 知识库分类 ====================
/**
@ -599,7 +649,7 @@ export async function fetchConversationList(
* 使 /ai/sdk/conversation/{id}/messages
*/
export async function fetchConversationMessages(conversationId: string): Promise<{
messages: Array<{ messageType: string; content: string; createTime: string }>;
messages: Array<{ messageType: string; content: string; createTime: string; images?: ImageAttachment[] }>;
total: number;
}> {
const params = new URLSearchParams();

132
client/src/chat.ts

@ -6,7 +6,7 @@
* userId accountId ID
* chatId /conversation/list
*/
import { ResolvedConfig, ChatMessage, RagSource } from './types';
import { ResolvedConfig, ChatMessage, RagSource, ImageAttachment } from './types';
import {
chatRequest,
chatSSERequest,
@ -26,6 +26,7 @@ import {
getActiveIntegrateId,
CskError,
fetchSuggestions,
uploadAttachment,
} from './api';
import {
renderUserBubble,
@ -83,6 +84,9 @@ let currentCategoryId: number | undefined;
/** 当前是否使用 RAG 对话 */
let useRag = false;
/** 待发送的图片列表(与 t-chat-sender 的 attachmentsProps.items 同步) */
let pendingImages: ImageAttachment[] = [];
/**
*
*/
@ -177,6 +181,12 @@ async function loadHistoryFromBackend(): Promise<void> {
id: uuid(),
role: msg.messageType === 'USER' ? 'user' : 'ai' as const,
content: msg.content,
images: (msg.images || []).map((i) => ({
name: i.name || '',
url: i.url,
mimeType: i.mimeType,
size: i.size,
})),
timestamp: new Date(msg.createTime).getTime(),
}));
@ -195,12 +205,20 @@ async function loadHistoryFromBackend(): Promise<void> {
function bindSendEvents(): void {
if (!inputEl) return;
// t-chat-sender:send 事件(点击发送/回车)携带 value;stop 事件(loading 态点击)中断流式
// t-chat-sender:send 事件(点击发送/回车)携带 value 与 attachments;stop 事件(loading 态点击)中断流式
inputEl.addEventListener('send', (e) => {
const detail = (e as CustomEvent).detail as { value?: string } | undefined;
const detail = (e as CustomEvent).detail as {
value?: string;
attachments?: Array<{ name?: string; url?: string; fileType?: string; size?: number }>;
} | undefined;
const value = (detail?.value || '').trim();
if (!value || isSending) return;
handleSend(value);
// 从附件中提取图片(本次仅图片参与多模态对话)
const images: ImageAttachment[] = (detail?.attachments || [])
.filter(a => a.fileType === 'image' && a.url)
.map(a => ({ name: a.name || '', url: a.url as string, size: a.size }));
if ((!value && images.length === 0) || isSending) return;
handleSend(value, images);
});
inputEl.addEventListener('stop', () => {
@ -209,11 +227,61 @@ function bindSendEvents(): void {
}
});
// t-chat-sender:fileSelect 事件(选择文件后触发,携带原始 File[])
inputEl.addEventListener('fileSelect', (e) => {
const files = (e as CustomEvent<File[]>).detail;
if (!files || files.length === 0) return;
handleFileSelect(files);
});
if (clearBtn) {
clearBtn.addEventListener('click', () => handleClear());
}
}
/**
* /attachment/upload
*/
async function handleFileSelect(files: File[]): Promise<void> {
if (!config) return;
for (const file of files) {
try {
const att = await uploadAttachment(file);
pendingImages.push(att);
} catch (err) {
const msg = err instanceof CskError ? err.message : t('error_upload_failed');
if (messagesContainer) renderErrorBubble(messagesContainer, msg, now());
logger.error('图片上传失败', err);
}
}
syncSenderAttachments();
}
/**
* pendingImages t-chat-sender attachmentsProps.items
* TDesign Chat
*/
function syncSenderAttachments(): void {
if (!inputEl) return;
const items = pendingImages.map(img => ({
name: img.name,
url: img.url,
fileType: 'image' as const,
size: img.size,
}));
(inputEl as unknown as {
attachmentsProps: { items: typeof items; overflow: string };
}).attachmentsProps = { items, overflow: 'scrollX' };
}
/** 清空待发送图片(发送成功后、清空会话、切换角色/会话时调用) */
function clearPendingImages(): void {
pendingImages = [];
syncSenderAttachments();
}
/** 绑定滚动监听:判断是否在底部,控制新消息提示按钮 */
function bindScrollEvents(): void {
if (!messagesContainer) return;
@ -404,16 +472,20 @@ function updateEmptyState(): void {
}
/** 处理发送消息(text 来自 t-chat-sender 的 send 事件,或快捷问题/重试的显式调用) */
async function handleSend(text?: string): Promise<void> {
async function handleSend(text?: string, images?: ImageAttachment[]): Promise<void> {
if (!config || isSending) return;
const input = (text ?? '').trim();
if (input === '') return;
const atts = images && images.length > 0 ? images : [...pendingImages];
if (input === '' && atts.length === 0) return;
// 清空待发送图片(快照已保存到 atts,发送失败时恢复)
clearPendingImages();
// 1. 渲染用户气泡
const userTimestamp = now();
const userMsg: ChatMessage = { id: uuid(), role: 'user', content: input, timestamp: userTimestamp };
if (messagesContainer) renderUserBubble(messagesContainer, input, userTimestamp);
const userMsg: ChatMessage = { id: uuid(), role: 'user', content: input, timestamp: userTimestamp, images: atts };
if (messagesContainer) renderUserBubble(messagesContainer, input, userTimestamp, atts);
messages.push(userMsg);
updateEmptyState();
@ -421,15 +493,21 @@ async function handleSend(text?: string): Promise<void> {
if (messagesContainer) smartScrollToBottom();
// 2. 生成 AI 回复
await produceAIReply(input);
const ok = await produceAIReply(input, atts);
// 发送失败时恢复待发送图片,方便用户重试
if (!ok && atts.length > 0) {
pendingImages = atts;
syncSenderAttachments();
}
}
/**
* AI + +
*
* @returns false
*/
async function produceAIReply(userText: string): Promise<void> {
if (!config || !messagesContainer) return;
async function produceAIReply(userText: string, images?: ImageAttachment[]): Promise<boolean> {
if (!config || !messagesContainer) return false;
isSending = true;
setSendButtonMode('stop');
@ -439,6 +517,9 @@ async function produceAIReply(userText: string): Promise<void> {
await initChatId();
}
// 提取图片 URL,仅图片参与多模态对话
const imageUrls = (images || []).map(img => img.url);
const aiTimestamp = now();
// RAG 启用条件:由 enableRag 控制
const shouldUseRag = useRag;
@ -452,9 +533,9 @@ async function produceAIReply(userText: string): Promise<void> {
try {
if (config.streaming) {
aiContent = await sendStreamMessage(userText, aiTimestamp, shouldUseRag, aiMsgId);
aiContent = await sendStreamMessage(userText, aiTimestamp, shouldUseRag, aiMsgId, imageUrls);
} else {
aiContent = await chatRequest(userText);
aiContent = await chatRequest(userText, imageUrls);
if (hideLoadingFn) hideLoadingFn();
if (messagesContainer) {
renderAIBubble(messagesContainer, aiContent, aiTimestamp, aiMsgId);
@ -484,6 +565,7 @@ async function produceAIReply(userText: string): Promise<void> {
// 发送成功后清除离线横幅(网络已恢复)
hideOfflineBanner();
return true;
} catch (err) {
if (hideLoadingFn) hideLoadingFn();
@ -492,6 +574,7 @@ async function produceAIReply(userText: string): Promise<void> {
renderErrorBubble(messagesContainer, errMsg, now());
}
logger.error(`发送失败 integrateId=${config.integrateId}`, err);
return false;
} finally {
isSending = false;
abortController = null;
@ -500,7 +583,7 @@ async function produceAIReply(userText: string): Promise<void> {
}
/** 流式发送消息 */
async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag: boolean, aiMsgId: string): Promise<string> {
async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag: boolean, aiMsgId: string, imageUrls?: string[]): Promise<string> {
// 创建中断控制器,供"停止生成"使用
abortController = new AbortController();
const signal = abortController.signal;
@ -535,7 +618,7 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag
// 无流内容降级为同步请求(须在 wrapperEl/bubbleEl 判断之外:
// 二者仅在 onChunk 收到首个 token 时才赋值,否则此分支不可达)
if (!streamStarted && accumulated === '') {
chatRequest(text).then(resolve).catch(reject);
chatRequest(text, imageUrls).then(resolve).catch(reject);
return;
}
if (wrapperEl && bubbleEl) {
@ -563,6 +646,7 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag
},
currentCategoryId,
shouldUseRag,
imageUrls,
signal
);
});
@ -597,7 +681,8 @@ export async function retryFromMessage(msgId: string): Promise<void> {
}
if (userIndex < 0) return;
const userText = messages[userIndex].content;
if (!userText) return;
const userImages = messages[userIndex].images || [];
if (!userText && userImages.length === 0) return;
// 计算 userTurn(1-based,仅统计 USER 消息):该轮提问是第几条用户消息
let userTurn = 0;
@ -623,7 +708,7 @@ export async function retryFromMessage(msgId: string): Promise<void> {
renderHistory();
// 4. 重新发送该轮提问(重新渲染用户气泡 + 生成新回复)
await handleSend(userText);
await handleSend(userText, userImages);
} finally {
isRetrying = false;
}
@ -694,7 +779,7 @@ function renderHistory(): void {
for (const msg of messages) {
if (msg.role === 'user') {
renderUserBubble(messagesContainer, msg.content, msg.timestamp);
renderUserBubble(messagesContainer, msg.content, msg.timestamp, msg.images);
} else {
const wrapper = renderAIBubble(messagesContainer, msg.content, msg.timestamp, msg.id, msg.feedback);
if (msg.sources && msg.sources.length > 0) renderSources(wrapper, msg.sources);
@ -731,6 +816,7 @@ function handleClear(): void {
if (clearBtn) clearBtn.style.display = 'none';
updateEmptyState();
clearMessages(config.integrateId);
clearPendingImages();
// 生成新的 chatId,开始新会话
const newId = generateNewChatId();
@ -789,6 +875,7 @@ export async function switchRole(newRoleId: string): Promise<void> {
messages = [];
const msgNodes = messagesContainer.querySelectorAll('.csk-msg, .csk-loading');
msgNodes.forEach(el => el.remove());
clearPendingImages();
// 5. 清空新旧角色的 localStorage 缓存(消息 + chatId),必须在 setActiveRoleId 之前
clearMessages(oldRoleId);
@ -928,6 +1015,7 @@ async function switchToConversation(conversationId: string): Promise<void> {
messages = [];
const msgs = messagesContainer.querySelectorAll('.csk-msg, .csk-loading');
msgs.forEach(el => el.remove());
clearPendingImages();
// 4. 从后端加载该会话的消息
try {
@ -937,6 +1025,12 @@ async function switchToConversation(conversationId: string): Promise<void> {
id: uuid(),
role: msg.messageType === 'USER' ? 'user' : 'ai' as const,
content: msg.content,
images: (msg.images || []).map((i) => ({
name: i.name || '',
url: i.url,
mimeType: i.mimeType,
size: i.size,
})),
timestamp: new Date(msg.createTime).getTime(),
}));

1
client/src/config.ts

@ -87,6 +87,7 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
rewriteStrategy: raw.rewriteStrategy || 'REWRITE',
locale: raw.locale || 'zh-CN',
debug: raw.debug ?? true,
allowImageUpload: raw.allowImageUpload ?? true,
sound: raw.sound ?? false,
notification: raw.notification ?? false,
onError: typeof raw.onError === 'function' ? raw.onError : undefined,

28
client/src/dom.ts

@ -7,7 +7,7 @@
* -
* -
*/
import { ResolvedConfig, RagSource } from './types';
import { ResolvedConfig, RagSource, ImageAttachment } from './types';
import { debounce, formatTime, formatHistoryTime } from './utils';
import { t } from './i18n';
@ -352,6 +352,8 @@ export function createChatWindow(config: ResolvedConfig): {
inputEl.setAttribute('placeholder', t('placeholder'));
// 压缩输入框:autosize 为 Object 复杂类型,走 property 赋值(同 dom.ts createChatItemMsg 的 content 处理)
(inputEl as unknown as { autosize: { minRows: number } }).autosize = { minRows: 1 };
// 启用附件按钮 + 发送按钮(actions 为 Array 复杂类型,走 property 赋值)
(inputEl as unknown as { actions: string[] }).actions = config.allowImageUpload ? ['attachment', 'send'] : ['send'];
inputArea.appendChild(inputEl);
// === 保密声明脚注(默认折叠为单行,点击展开完整条文) ===
@ -823,11 +825,31 @@ function createChatItemMsg(role: 'user' | 'assistant', content: unknown[], times
return msg;
}
export function renderUserBubble(container: HTMLElement, text: string, timestamp: number): HTMLElement {
export function renderUserBubble(
container: HTMLElement,
text: string,
timestamp: number,
images?: ImageAttachment[]
): HTMLElement {
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--user';
wrapper.appendChild(createChatItemMsg('user', [{ type: 'text', data: text }], timestamp));
// 组装 content:文本 + 附件(TDesign Chat 的 attachment content 类型,复用组件原生渲染)
const content: Array<{ type: string; data: unknown }> = [];
if (text) content.push({ type: 'text', data: text });
if (images && images.length > 0) {
content.push({
type: 'attachment',
data: images.map(img => ({
name: img.name,
url: img.url,
fileType: 'image',
size: img.size,
})),
});
}
wrapper.appendChild(createChatItemMsg('user', content, timestamp));
container.appendChild(wrapper);

2
client/src/i18n.ts

@ -74,6 +74,7 @@ const dictionaries: Record<string, Record<string, string>> = {
error_unavailable: '服务暂不可用,请稍后重试',
error_unknown: '请求发生未知错误',
error_send: '发送失败,请稍后重试',
error_upload_failed: '图片上传失败,请稍后重试',
error_stream_unsupported: '浏览器不支持流式读取',
},
@ -147,6 +148,7 @@ const dictionaries: Record<string, Record<string, string>> = {
error_unavailable: 'Service temporarily unavailable',
error_unknown: 'Unknown request error',
error_send: 'Failed to send, please try again',
error_upload_failed: 'Image upload failed, please try again',
error_stream_unsupported: 'Browser does not support streaming',
},
};

1
client/src/index.ts

@ -28,6 +28,7 @@ export type {
SDKConfig,
ResolvedConfig,
ChatMessage,
ImageAttachment,
RagSource,
CategoryNode,
ChatbotSDKInstance,

20
client/src/types.ts

@ -83,6 +83,10 @@ export interface SDKConfig {
/** 是否输出调试日志,默认 true */
debug?: boolean;
// === 图片上传配置 ===
/** 是否允许上传图片参与对话,默认 true */
allowImageUpload?: boolean;
// === 通知配置 ===
/** 弹窗关闭时收到新消息是否播放提示音,默认 false */
sound?: boolean;
@ -168,6 +172,20 @@ export interface ResolvedConfig {
onMessage?: (msg: ChatMessage) => void;
/** 当前对话 ID(自动管理,从 /conversation/list 获取或自动生成) */
chatId: string;
/** 是否允许上传图片参与对话 */
allowImageUpload: boolean;
}
/** 图片附件 */
export interface ImageAttachment {
/** 文件名 */
name: string;
/** 可访问的 URL */
url: string;
/** MIME 类型 */
mimeType?: string;
/** 文件大小(字节) */
size?: number;
}
/** 聊天消息 */
@ -180,6 +198,8 @@ export interface ChatMessage {
content: string;
/** 时间戳(毫秒) */
timestamp: number;
/** 可选:用户消息携带的图片 */
images?: ImageAttachment[];
/** 可选:RAG 引用来源 */
sources?: RagSource[];
/** 可选:用户反馈 'up' | 'down',预留后端对接位 */

10
frontend/UI-DEV-GUIDE.md

@ -133,7 +133,7 @@ import { palette, paletteBg, paletteBorder } from '@/utils/palette'
</div>
<!-- ② 表格 -->
<t-table
<BaseTable
:data="pagination.data.value"
:columns="columns"
: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-space>
</template>
</t-table>
</BaseTable>
</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>` 标签写法 |
| 分页 | 统一用 `<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 个页面缺失) |
| pageSize | 默认统一 20(现状 10/20/30 三档不一) |
| 操作列 | `<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`)。
9. 表格缺 `:loading`:`UserManager` / `AccountManager` / `McpServerManager` / `ConversationManager`
9. ~~表格缺 `:loading`:`UserManager` / `AccountManager` / `McpServerManager` / `ConversationManager`。~~(已补齐,且列表表格统一由 `BaseTable` 承载)
10. 分页缺失/不统一:`AccountManager` 无分页;pageSize 10/20/30 三档。
11. 表单校验分裂:全无 `:rules`,手写 `if``:disabled` 两种土办法并存。
12. 图标体系分裂:emoji 主导,仅 `SystemConfigManager` 用 tdesign-icons;`ModelConfigManager` 操作列纯 emoji 按钮。

5
frontend/components.d.ts

@ -7,12 +7,14 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
BaseTable: typeof import('./src/components/BaseTable.vue')['default']
FormDialog: typeof import('./src/components/FormDialog.vue')['default']
FormDrawer: typeof import('./src/components/FormDrawer.vue')['default']
MessageSources: typeof import('./src/components/MessageSources.vue')['default']
PromptSearchHighlight: typeof import('./src/components/PromptSearchHighlight.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
TAlert: typeof import('tdesign-vue-next')['Alert']
TButton: typeof import('tdesign-vue-next')['Button']
TCard: typeof import('tdesign-vue-next')['Card']
TCheckbox: typeof import('tdesign-vue-next')['Checkbox']
@ -24,6 +26,7 @@ declare module 'vue' {
TDialog: typeof import('tdesign-vue-next')['Dialog']
TDivider: typeof import('tdesign-vue-next')['Divider']
TDrawer: typeof import('tdesign-vue-next')['Drawer']
TDropdown: typeof import('tdesign-vue-next')['Dropdown']
TEmpty: typeof import('tdesign-vue-next')['Empty']
TForm: typeof import('tdesign-vue-next')['Form']
TFormItem: typeof import('tdesign-vue-next')['FormItem']
@ -47,6 +50,8 @@ declare module 'vue' {
TTextarea: typeof import('tdesign-vue-next')['Textarea']
TTimeline: typeof import('tdesign-vue-next')['Timeline']
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']
}
}

39
frontend/src/App.vue

@ -6,11 +6,11 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, onUnmounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import LoginPage from '@/views/LoginPage.vue'
import MainLayout from '@/layouts/MainLayout.vue'
import { isLoggedIn, clearTokens } from '@/utils/token'
import { isLoggedIn, cleanupLegacyTokens } from '@/utils/token'
import { toast } from '@/utils/toast'
const auth = useAuthStore()
@ -19,30 +19,39 @@ function onLoginSuccess(user: any) {
auth.onLoginSuccess(user)
}
// 401
async function handleUnauthorized() {
await auth.doLogout()
toast('登录已过期,请重新登录', 'error')
}
onMounted(async () => {
// refresh token
cleanupLegacyTokens()
//
if (isLoggedIn()) {
const ok = await auth.fetchUser()
if (!ok) {
// Token
// access token refresh cookie token
const refreshed = await auth.tryRefreshToken()
if (refreshed) {
await auth.fetchUser().catch(() => {})
// /auth/me
// currentUser
const okAfterRefresh = await auth.fetchUser()
if (!okAfterRefresh) {
await auth.doLogout()
}
} else {
auth.logout()
await auth.doLogout()
}
}
}
// 401
window.addEventListener('auth:unauthorized', async () => {
const refreshed = await auth.tryRefreshToken()
if (refreshed) {
await auth.fetchUser().catch(() => {})
} else {
auth.logout()
toast('登录已过期,请重新登录', 'error')
}
})
window.addEventListener('auth:unauthorized', handleUnauthorized)
})
onUnmounted(() => {
window.removeEventListener('auth:unauthorized', handleUnauthorized)
})
</script>

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

@ -1,8 +1,11 @@
import request from './request'
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 条) */
export function listAllApiKeys(): Promise<ApiResponse> {

16
frontend/src/api/auth.ts

@ -6,9 +6,19 @@ export function login(username: string, password: string): Promise<ApiResponse<T
return request.post('/auth/login', { username, password }).then(r => r.data)
}
/** 刷新 Token */
export function refreshToken(refreshToken: string): Promise<ApiResponse<TokenResponse>> {
return request.post('/auth/refresh', { refreshToken }).then(r => r.data)
/** 刷新 Token(refresh token 通过 httpOnly Cookie 自动携带) */
export function refreshToken(): Promise<ApiResponse<TokenResponse>> {
return request.post('/auth/refresh').then(r => r.data)
}
/** 退出登录(清除服务端 refresh token cookie) */
export function logout(): Promise<ApiResponse<void>> {
return request.post('/auth/logout').then(r => r.data)
}
/** 当前登录用户自助修改密码 */
export function changePassword(oldPassword: string, newPassword: string): Promise<ApiResponse<void>> {
return request.put('/auth/password', { oldPassword, newPassword }).then(r => r.data)
}
/** 获取当前登录用户信息 */

4
frontend/src/api/conversation.ts

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

19
frontend/src/api/document.ts

@ -12,12 +12,14 @@ function authHeaders(): Record<string, string> {
// ==================== 文档 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, folderId?: string, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/document/list?page=${page}&size=${size}`
if (categoryId) path += `&categoryId=${categoryId}`
if (folderId != null) path += `&folderId=${folderId}`
if (status) path += `&status=${status}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (tag) path += `&tag=${encodeURIComponent(tag)}`
if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}`
return request.get(path).then(r => r.data)
}
@ -95,9 +97,9 @@ export function batchToggleDocuments(ids: string[], enabled: boolean): Promise<A
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)
}
/** 更新单个分块内容 */
@ -125,10 +127,3 @@ export function searchDocuments(query: string, topK?: number, similarityThreshol
export function getStats(): Promise<ApiResponse> {
return request.get('/document/stats').then(r => r.data)
}
// ==================== 标签 ====================
/** 获取标签列表 */
export function getTagList(): Promise<ApiResponse> {
return request.get('/tag/list').then(r => r.data)
}

4
frontend/src/api/faq.ts

@ -1,11 +1,13 @@
import request from './request'
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}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (categoryId) path += `&categoryId=${categoryId}`
if (status) path += `&status=${encodeURIComponent(status)}`
if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}`
return request.get(path).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
apiKeyId?: 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.apiKeyId) params.set('apiKeyId', query.apiKeyId)
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)
}

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

@ -1,12 +1,17 @@
import request from './request'
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 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 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 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) }

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

@ -1,9 +1,11 @@
import request from './request'
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}`
if (appType) path += `&appType=${encodeURIComponent(appType)}`
if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}`
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) }

74
frontend/src/api/request.ts

@ -3,13 +3,15 @@
* api.js fetch
*/
import axios from 'axios'
import { getToken } from '@/utils/token'
import { getToken, setToken, clearTokens } from '@/utils/token'
/** 同源部署,API 基址为空 */
const request = axios.create({
baseURL: '',
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
// 允许跨域携带 Cookie(refresh token 使用 httpOnly Cookie)
withCredentials: true,
})
/** 请求拦截器:自动附加 Bearer Token */
@ -22,18 +24,74 @@ request.interceptors.request.use((config) => {
})
/**
* 401 auth:unauthorized
* app.js Token
* Token
* - 401 /auth/refresh token
* -
*/
let isRefreshing = false
let pendingQueue: Array<(token: string) => void> = []
/**
* 401 access token
* /auth/login /auth/refresh 401
*/
request.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('sb_token')
localStorage.removeItem('sb_user')
async (error) => {
const { config, response } = error
const url: string = config?.url || ''
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 后重试
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingQueue.push((token: string) => {
if (token) {
resolve(request(config))
} else {
reject(new Error(response?.data?.message || error.message || '网络错误'))
}
})
})
}
// 发起刷新(用「裸 axios」而不走当前实例,避免 refresh 自身再次进入本拦截器造成递归;
// 其 401 也会被上方 url 判断跳过,不会二次刷新)
isRefreshing = true
try {
const res = await axios.post('/auth/refresh', null, { withCredentials: true })
const newToken = res.data?.data?.accessToken
if (newToken) {
setToken(newToken)
// 放行所有挂起请求(请求拦截器会自动附加新 token)
pendingQueue.forEach((cb) => cb(newToken))
pendingQueue = []
// 重试当前请求(config._retry 已为 true,若仍 401 会直接失败而不再刷新)
return request(config)
}
throw new Error('刷新令牌失败')
} catch (refreshErr) {
// 刷新失败:清除本地凭证并触发全局登出
clearTokens()
pendingQueue.forEach((cb) => cb(''))
pendingQueue = []
window.dispatchEvent(new Event('auth:unauthorized'))
return Promise.reject(new Error(response?.data?.message || error.message || '网络错误'))
} finally {
isRefreshing = false
}
}
const message = error.response?.data?.message || error.message || '网络错误'
const message = response?.data?.message || error.message || '网络错误'
return Promise.reject(new Error(message))
}
)

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

@ -1,11 +1,13 @@
import request from './request'
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}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (category) path += `&category=${encodeURIComponent(category)}`
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)
}
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 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 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}`
if (sessionId) path += `&sessionId=${encodeURIComponent(sessionId)}`
if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}`
return request.get(path).then(r => r.data)
}

39
frontend/src/api/upload.ts

@ -1,26 +1,22 @@
import { getToken } from '@/utils/token'
import request from './request'
import type { ApiResponse } from '@/types/api'
/** XMLHttpRequest 上传(带进度回调)—— 保留旧版 api.js 的核心实现 */
function postFormWithProgress(path: string, formData: FormData, onProgress?: (pct: number) => void): Promise<any> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', path)
const token = getToken()
if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token)
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable && onProgress) {
/**
* axios request
* Bearer Token401
* axios onUploadProgress
*/
function postFormWithProgress(path: string, formData: FormData, onProgress?: (pct: number) => void): Promise<ApiResponse> {
return request.post(path, formData, {
// 必须显式指定 multipart/form-data:request 实例默认 Content-Type 为 application/json,
// 若不覆盖,后端会将请求视为非 multipart 而抛 MultipartException(边界由浏览器自动补充)。
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (e.total && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100))
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 401) { localStorage.removeItem('sb_token'); localStorage.removeItem('sb_user'); window.dispatchEvent(new Event('auth:unauthorized')); reject(new Error('401')); return }
try { resolve(JSON.parse(xhr.responseText)) } catch { reject(new Error('响应解析失败')) }
})
xhr.addEventListener('error', () => reject(new Error('网络错误')))
xhr.addEventListener('abort', () => reject(new Error('上传已取消')))
xhr.send(formData)
})
},
}).then(r => r.data)
}
/** 上传文件(带进度) */
@ -28,6 +24,11 @@ export function uploadFile(formData: FormData, onProgress?: (pct: number) => voi
return postFormWithProgress('/upload/file', formData, onProgress)
}
/** 上传文件夹(批量文件 + 相对路径,复制目录结构) */
export function uploadFolder(formData: FormData, onProgress?: (pct: number) => void): Promise<ApiResponse> {
return postFormWithProgress('/upload/folder', formData, onProgress)
}
/** 上传 Markdown(带进度) */
export function uploadMarkdown(formData: FormData, onProgress?: (pct: number) => void): Promise<ApiResponse> {
return postFormWithProgress('/upload/markdown', formData, onProgress)

4
frontend/src/api/user.ts

@ -1,10 +1,12 @@
import request from './request'
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}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
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)
}
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 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 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) }

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>

4
frontend/src/layouts/MainLayout.vue

@ -50,8 +50,8 @@ const mobileMenuOpen = ref(false)
//
provide('openDocDetail', (docId: string) => dialog.openDetail(docId))
function handleLogout() {
auth.logout()
async function handleLogout() {
await auth.doLogout()
toast('已退出登录', 'info')
router.push('/chat')
}

91
frontend/src/layouts/Topbar.vue

@ -13,21 +13,108 @@
<span class="user-name">{{ displayName }}</span>
<t-tag v-for="r in roles" :key="r" size="small" variant="light" theme="primary">{{ r }}</t-tag>
</span>
<t-button size="small" variant="outline" theme="default" @click="openPasswordDialog">改密</t-button>
<t-button size="small" variant="outline" theme="default" @click="$emit('logout')">退出</t-button>
</div>
<t-dialog
v-model:visible="passwordVisible"
header="修改密码"
width="400px"
:confirm-btn="{ content: '确认修改', loading: passwordLoading }"
:on-confirm="submitPassword"
@close="resetPasswordForm"
>
<t-form :data="passwordForm" :rules="passwordRules" label-width="80px">
<t-form-item label="旧密码" name="oldPassword">
<t-input v-model="passwordForm.oldPassword" type="password" placeholder="请输入旧密码" />
</t-form-item>
<t-form-item label="新密码" name="newPassword">
<t-input v-model="passwordForm.newPassword" type="password" placeholder="8-64 位,含字母和数字" />
</t-form-item>
<t-form-item label="确认密码" name="confirmPassword">
<t-input v-model="passwordForm.confirmPassword" type="password" placeholder="请再次输入新密码" />
</t-form-item>
</t-form>
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, reactive, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { changePassword } from '@/api/auth'
import { toast } from '@/utils/toast'
defineEmits<{ toggleMobileMenu: []; logout: [] }>()
const emit = defineEmits<{ toggleMobileMenu: []; logout: [] }>()
const auth = useAuthStore()
const displayName = computed(() => auth.currentUser.nickname || auth.currentUser.username || '')
const avatarChar = computed(() => (auth.currentUser.nickname || auth.currentUser.username || 'U').charAt(0))
const roles = computed(() => auth.currentUser.roles || [])
//
const passwordVisible = ref(false)
const passwordLoading = ref(false)
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: '',
})
const passwordRules = {
oldPassword: [{ required: true, message: '请输入旧密码' }],
newPassword: [{ required: true, message: '请输入新密码' }],
confirmPassword: [{ required: true, message: '请确认新密码' }],
}
function openPasswordDialog() {
resetPasswordForm()
passwordVisible.value = true
}
function resetPasswordForm() {
passwordForm.oldPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
}
async function submitPassword() {
if (!passwordForm.oldPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
toast('请填写完整密码信息', 'error')
return
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast('两次输入的新密码不一致', 'error')
return
}
if (passwordForm.newPassword.length < 8 || passwordForm.newPassword.length > 64) {
toast('新密码长度需在 8-64 位之间', 'error')
return
}
const hasLetter = /[a-zA-Z]/.test(passwordForm.newPassword)
const hasDigit = /\d/.test(passwordForm.newPassword)
if (!hasLetter || !hasDigit) {
toast('新密码需同时包含字母和数字', 'error')
return
}
passwordLoading.value = true
try {
const res = await changePassword(passwordForm.oldPassword, passwordForm.newPassword)
if (res.success) {
toast(res.message || '密码修改成功,请重新登录', 'success')
passwordVisible.value = false
// token 退
emit('logout')
} else {
toast(res.message || '修改失败', 'error')
}
} catch (e: any) {
toast(e.message || '修改失败', 'error')
}
passwordLoading.value = false
}
</script>
<style scoped>

1
frontend/src/router/index.ts

@ -62,7 +62,6 @@ router.beforeEach(async (to, _from, next) => {
await Promise.all([
categoryStore.loadCategories(),
documentStore.loadStats(),
documentStore.loadTags(),
])
} catch { /* 预加载失败不阻塞导航 */ }
break

18
frontend/src/stores/auth.ts

@ -3,8 +3,8 @@
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getToken, isLoggedIn, clearTokens, getUserInfo, setUserInfo, setToken, setRefreshToken } from '@/utils/token'
import { getCurrentUser, refreshToken } from '@/api/auth'
import { getToken, isLoggedIn, clearTokens, getUserInfo, setUserInfo, setToken, cleanupLegacyTokens } from '@/utils/token'
import { getCurrentUser, refreshToken, logout } from '@/api/auth'
export const useAuthStore = defineStore('auth', () => {
const currentUser = ref<Record<string, any>>(getUserInfo())
@ -30,20 +30,20 @@ export const useAuthStore = defineStore('auth', () => {
}
async function tryRefreshToken(): Promise<boolean> {
const rt = localStorage.getItem('sb_refresh_token')
if (!rt) return false
try {
const res = await refreshToken(rt)
const res = await refreshToken()
if (res.success && res.data) {
setToken(res.data.accessToken)
setRefreshToken(res.data.refreshToken)
return true
}
} catch { /* ignore */ }
return false
}
function logout(): void {
async function doLogout(): Promise<void> {
try {
await logout()
} catch { /* ignore */ }
clearTokens()
loggedIn.value = false
currentUser.value = {}
@ -53,7 +53,9 @@ export const useAuthStore = defineStore('auth', () => {
currentUser.value = user || getUserInfo()
setUserInfo(user || {})
loggedIn.value = true
// 清理历史遗留的 localStorage refresh token
cleanupLegacyTokens()
}
return { currentUser, loggedIn, isAdmin, hasRole, fetchUser, tryRefreshToken, logout, onLoginSuccess }
return { currentUser, loggedIn, isAdmin, hasRole, fetchUser, tryRefreshToken, doLogout, onLoginSuccess }
})

14
frontend/src/stores/document.ts

@ -1,13 +1,12 @@
/**
* store.js stats / tags
* store.js stats
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getStats, getTagList } from '@/api/document'
import { getStats } from '@/api/document'
export const useDocumentStore = defineStore('document', () => {
const stats = ref<any>(null)
const tags = ref<any[]>([])
async function loadStats(): Promise<void> {
try {
@ -16,12 +15,5 @@ export const useDocumentStore = defineStore('document', () => {
} catch (e) { console.error('加载统计失败', e) }
}
async function loadTags(): Promise<void> {
try {
const res = await getTagList()
if (res.success) tags.value = res.data || []
} catch (e) { console.error('加载标签列表失败', e) }
}
return { stats, tags, loadStats, loadTags }
return { stats, loadStats }
})

7
frontend/src/styles/shared.css

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

21
frontend/src/types/models.ts

@ -15,10 +15,12 @@ export interface LoginRequest {
password: string
}
/** Token 响应 */
/** Token
* refreshToken httpOnly Cookie body
*/
export interface TokenResponse {
accessToken: string
refreshToken: string
refreshToken?: string
}
// ==================== 知识库文档 ====================
@ -28,6 +30,7 @@ export interface KnowledgeDocument {
id: string
title: string
categoryId: string
folderId?: string
categoryName?: string
tags?: string[]
status: 'processing' | 'ready' | 'error'
@ -60,6 +63,20 @@ export interface 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 条目 */

4
frontend/src/utils/sse.ts

@ -49,7 +49,7 @@ export async function readSSEStream(
headers?: Record<string, string>,
signal?: AbortSignal
): Promise<void> {
const res = await fetch(url, { headers: authHeaders(headers), signal })
const res = await fetch(url, { headers: authHeaders(headers), signal, credentials: 'include' })
if (!res.ok) throw new Error('HTTP ' + res.status)
const reader = res.body!.getReader()
const decoder = new TextDecoder()
@ -130,7 +130,7 @@ export async function readSSEStreamWithEvents(
signal?: AbortSignal
): Promise<void> {
const { onMessage, onToolCallStart, onToolCallResult, onError, onDone } = handlers
const res = await fetch(url, { headers: authHeaders(headers), signal })
const res = await fetch(url, { headers: authHeaders(headers), signal, credentials: 'include' })
if (!res.ok) throw new Error('HTTP ' + res.status)
const reader = res.body!.getReader()
const decoder = new TextDecoder()

21
frontend/src/utils/token.ts

@ -1,10 +1,9 @@
/**
* Token
* localStorage JWT Token
* Access Token
* localStorage access token refresh token httpOnly Cookie
*/
const TOKEN_KEY = 'sb_token'
const REFRESH_TOKEN_KEY = 'sb_refresh_token'
const USER_KEY = 'sb_user'
export function getToken(): string {
@ -15,17 +14,8 @@ export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
export function getRefreshToken(): string {
return localStorage.getItem(REFRESH_TOKEN_KEY) || ''
}
export function setRefreshToken(token: string): void {
localStorage.setItem(REFRESH_TOKEN_KEY, token)
}
export function clearTokens(): void {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
@ -44,3 +34,10 @@ export function setUserInfo(info: Record<string, any>): void {
export function isLoggedIn(): boolean {
return !!getToken()
}
/**
* refresh token
*/
export function cleanupLegacyTokens(): void {
localStorage.removeItem('sb_refresh_token')
}

11
frontend/src/views/AccountManager.vue

@ -5,7 +5,7 @@
<t-button theme="primary" size="small" @click="openCreate"> 新建账号</t-button>
<t-button variant="outline" size="small" @click="loadList">🔄 刷新</t-button>
</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 #op="{row}">
<t-space :size="4">
@ -13,7 +13,7 @@
<t-button size="small" variant="text" theme="danger" @click="doDelete(row.id)">删除</t-button>
</t-space>
</template>
</t-table>
</BaseTable>
<t-dialog v-model:visible="modal.visible" :header="modal.isEdit?'编辑账号':'新建账号'" width="480px" :footer="false">
<t-form label-align="top">
@ -35,15 +35,16 @@ import { useConfirm } from '@/composables/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:''})
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 openEdit(a:any){Object.assign(modal,{visible:true,isEdit:true,id:a.id,name:a.name||''})}

14
frontend/src/views/ApiKeyManager.vue

@ -5,8 +5,8 @@
<div style="flex:1;" /><span class="total-hint"> {{ total }} Key</span>
</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}">
<code class="key-code">{{ maskKey(row.keyValue) }}</code>
<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-space>
</template>
</t-table>
</BaseTable>
<!-- 新建弹窗 -->
<t-dialog v-model:visible="showCreateDialog" header="新建 API Key" width="520px" :footer="false">
@ -67,7 +67,7 @@
<!-- 角色绑定弹窗 -->
<t-dialog v-model:visible="showRoleDialog" :header="'绑定角色 — '+bindingKeyName" width="480px" :footer="false">
<t-checkbox-group v-model="selectedRoleIds">
<t-checkbox v-for="r in allRoles" :key="r.id" :value="String(r.id)">{{ r.name }} ({{ r.roleKey }})</t-checkbox>
<t-checkbox v-for="r in allRoles" :key="r.id" :value="String(r.id)">{{ r.name }}</t-checkbox>
</t-checkbox-group>
<div class="dialog-footer">
<t-button variant="outline" @click="showRoleDialog=false">取消</t-button>
@ -95,7 +95,8 @@ const form=ref({name:'',description:'',rateLimit:60})
const createdKey=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()})
@ -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}}
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 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)}}

12
frontend/src/views/AuditLogManager.vue

@ -1,13 +1,13 @@
<template>
<t-card title="📋 内容审计日志" :bordered="false">
<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 #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 #hitWords="{row}"><span class="hit-words">{{ formatHitWords(row.hitWords) }}</span></template>
</t-table>
</BaseTable>
</t-card>
</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 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())
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 onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;loadLogs()}
function formatHitWords(hw:any):string{
if(!hw)return'';if(hw.type==='jsonb'&&hw.value){try{hw=JSON.parse(hw.value)}catch{return hw.value}}

25
frontend/src/views/CategoryManager.vue

@ -4,10 +4,8 @@
<div class="create-bar card-box">
<h3 class="create-title">创建新分类</h3>
<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="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-space>
</div>
@ -26,7 +24,6 @@
<span v-else style="width:16px;" />
<strong class="tree-name">{{ node.name }}</strong>
<span v-if="node.description" class="tree-desc">{{ node.description }}</span>
<span class="tree-doc-count">({{ node.documentCount||0 }} )</span>
</div>
<t-space :size="4">
<t-button size="small" variant="text" @click="openEdit(node)">编辑</t-button>
@ -40,7 +37,6 @@
<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.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>
<div class="dialog-footer">
<t-button variant="outline" @click="closeEdit">取消</t-button>
@ -61,16 +57,10 @@ const { confirm } = useConfirm()
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 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 cats = categoryStore.categories||[]; const m=new Map<string,any>(); const roots:any[]=[]
@ -96,14 +86,14 @@ function expandAll(){const s=new Set<string>();function walk(nodes:any[]){for(co
onMounted(async () => { await categoryStore.loadCategories(); setTimeout(expandAll,100) })
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}
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')}}
async function remove(node:any){const cc=countDescendants(node);const m=cc>0?`\n注意:下有 ${cc} 个子分类`:''
@ -120,5 +110,4 @@ function countDescendants(node:any):number{if(!node.children?.length)return 0;le
.tree-arrow{cursor:pointer;user-select:none;font-size:12px;width:16px;text-align:center;color:var(--color-text-tertiary);}
.tree-name{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.tree-desc{color:var(--color-text-tertiary);font-size:12px;margin-left:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.tree-doc-count{color:var(--color-text-tertiary);font-size:11px;margin-left:8px;white-space:nowrap;}
</style>

16
frontend/src/views/ConversationManager.vue

@ -17,8 +17,8 @@
<t-button variant="outline" size="small" @click="load()">刷新</t-button>
</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}">
<t-space :size="4">
<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-space>
</template>
</t-table>
</BaseTable>
<!-- 消息详情弹窗 -->
<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 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 roles=ref<any[]>([]);const stats=ref<any>(null)
const msgModal=ref({visible:false,conversationId:'',roleName:'',messages:[] as any[]})
@ -112,7 +112,8 @@ const canSubmitFaq = computed(() => {
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()})
@ -130,9 +131,10 @@ function reasonLabel(c: string): string {
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 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)}}

6
frontend/src/views/DashboardPanel.vue

@ -34,7 +34,7 @@
<t-row :gutter="16">
<t-col :span="6">
<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-card>
</t-col>
@ -79,8 +79,8 @@ const satisfactionChartRef = ref<HTMLCanvasElement | null>(null)
const hitColumns = [
{ colKey: 'idx', title: '排名', width: 50, cell: (_h: any, { rowIndex }: any) => rowIndex + 1 },
{ 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(() => {

11
frontend/src/views/DocDetail.vue

@ -19,16 +19,14 @@
<span class="info-label">标签</span>
<t-tag v-for="t in getTags()" :key="t" size="small" variant="light" theme="primary" style="margin-right:4px;">{{ t }}</t-tag>
</div>
<div v-if="doc.errorMessage" class="info-error">
<span class="info-label">错误信息</span><span class="error-text">{{ doc.errorMessage }}</span>
</div>
<t-alert v-if="doc.errorMessage" theme="error" title="错误信息" :message="doc.errorMessage" style="margin-top:8px;" />
</t-tab-panel>
<t-tab-panel value="content" label="📄 原文内容">
<div v-if="doc.content" class="raw-content">
<div class="raw-header">
<span class="raw-title">{{ doc.sourceName || doc.title || '原文' }}</span>
<t-tag v-if="doc.content && doc.content.length >= 2000" size="small" theme="warning" variant="light">内容已截断仅显示前2000字符</t-tag>
<t-tag v-if="doc.contentTruncated" size="small" theme="warning" variant="light">内容已截断仅显示前2000字符</t-tag>
</div>
<pre class="raw-body">{{ doc.content }}</pre>
</div>
@ -36,7 +34,7 @@
</t-tab-panel>
<t-tab-panel value="chunks" label="🧩 分块列表">
<div v-if="chunksError" class="error-msg">{{ chunksError }}</div>
<t-alert v-if="chunksError" theme="error" :message="chunksError" style="margin:8px 0;" />
<div v-else-if="chunks.length" class="chunks-wrapper">
<div class="chunks-summary"> {{ chunks.length }} 个分块</div>
<div v-for="(chunk, idx) in chunks" :key="idx" class="chunk-card">
@ -104,8 +102,6 @@ function getTags(): string[] {
.info-label { width: 70px; flex: none; font-size: 13px; color: var(--color-text-tertiary); }
.info-val { font-size: 13px; color: var(--color-text-primary); word-break: break-all; }
.info-tags { margin-top: 12px; display: flex; align-items: center; }
.info-error { margin-top: 8px; display: flex; align-items: flex-start; gap: 8px; }
.error-text { color: var(--color-error); font-size: 12px; }
/* 原文 */
.raw-content { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; overflow: hidden; }
@ -125,6 +121,5 @@ function getTags(): string[] {
.chunk-footer { margin-top: 8px; padding-top: 6px; border-top: 1px dashed var(--color-border); display: flex; justify-content: flex-end; }
.chunk-len { font-size: 11px; color: var(--color-text-disabled); }
.error-msg { color: var(--color-error); padding: 8px 0; }
.empty-detail { color: var(--color-text-tertiary); padding: 40px; text-align: center; }
</style>

496
frontend/src/views/DocList.vue

@ -1,13 +1,51 @@
<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">
<t-loading v-if="folderTreeLoading" text="加载中..." size="small" />
<t-tree v-else
:data="treeData"
:keys="{ value: 'value', label: 'label', children: 'children' }"
:activable="true"
:active-multiple="false"
hover
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">
<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="filterTag" :options="tagOptions" placeholder="全部标签" clearable size="small" style="width:140px;" @change="load()" />
<t-button variant="outline" size="small" @click="load()">刷新</t-button>
<t-tag v-if="hasProcessing" theme="warning" size="small"> 有文档处理中...</t-tag>
<t-tag v-if="filterFolder" theme="primary" variant="light" size="small" :closable="true" @close="clearFolderFilter">📁 目录筛选</t-tag>
<template v-if="selectedIds.size > 0">
<t-divider layout="vertical" />
@ -16,19 +54,22 @@
<t-button variant="outline" size="small" @click="batchReprocess">批量重新处理</t-button>
<t-button variant="outline" size="small" @click="batchEnable">批量启用</t-button>
<t-button variant="outline" size="small" @click="batchDisable">批量禁用</t-button>
<t-select v-model="moveCategoryId" :options="moveCategoryOptions" placeholder="移动到分类..." size="small" style="width:140px;" />
<t-button variant="outline" size="small" @click="batchMove" :disabled="moveCategoryId === ''">移动</t-button>
<t-button variant="outline" size="small" @click="openMove(Array.from(selectedIds))">移动</t-button>
<t-button variant="text" size="small" @click="clearSelection">取消选择</t-button>
</template>
</div>
<!-- 表格 -->
<t-table
<!-- 表格外层容器撑满剩余高度表格在其中内部滚动表头固定 -->
<div class="doc-table-wrap">
<BaseTable
:max-height="'calc(100vh - 220px)'"
:data="documents" :columns="columns" row-key="id" :loading="loading"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }"
:selected-row-keys="Array.from(selectedIds)"
:sort="sortInfo"
@page-change="onPageChange"
@select-change="onSelectChange"
@sort-change="onSortChange"
>
<template #title="{ row }">
<div><strong>{{ row.title }}</strong></div>
@ -39,7 +80,9 @@
</template>
<template #fileType="{ row }"><t-tag size="small" variant="light">{{ row.fileType }}</t-tag></template>
<template #fileSize="{ row }"><span style="white-space:nowrap;">{{ formatSize(row.fileSize) }}</span></template>
<template #categoryId="{ row }"><span>{{ categoryStore.getCategoryName(row.categoryId) }}</span></template>
<template #categoryId="{ row }">
<span class="doc-pos" :title="docPath(row)">{{ docPath(row) }}</span>
</template>
<template #status="{ row }">
<t-tag size="small" :theme="row.status === 'READY' ? 'success' : row.status === 'PROCESSING' ? 'warning' : 'danger'"
variant="light">{{ statusLabel(row.status) }}</t-tag>
@ -51,24 +94,65 @@
<t-space :size="2">
<t-button size="small" variant="text" @click="viewDetail(row.id)">查看</t-button>
<t-button v-show="row.filePath" size="small" variant="text" @click="download(row.id)">下载</t-button>
<t-button size="small" variant="text" @click="openMove([row.id])">移动</t-button>
<t-button size="small" variant="text" @click="reprocess(row.id)">重新处理</t-button>
<t-button size="small" variant="text" theme="danger" @click="remove(row.id)">删除</t-button>
</t-space>
</template>
</t-table>
</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>
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useDocumentStore } from '@/stores/document'
import { useCategoryStore } from '@/stores/category'
import { useDialogStore } from '@/stores/dialog'
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 { formatBytes, formatDate } from '@/utils/format'
import { useConfirm } from '@/composables/useConfirm'
import { useDebounce } from '@/composables/useDebounce'
import { MoreIcon } from 'tdesign-icons-vue-next'
const { confirm } = useConfirm()
const { debounce } = useDebounce()
@ -86,11 +170,40 @@ const loading = ref(false)
const filterCategory = ref('')
const filterStatus = ref('')
const keyword = ref('')
const filterTag = ref('')
const selectedIds = ref(new Set<string>())
const moveCategoryId = ref('')
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
@ -112,27 +225,31 @@ function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
onUnmounted(() => { stopPolling() })
onUnmounted(() => { stopPolling(); if (resizeCleanup) resizeCleanup() })
// ==================== ====================
const columns = [
{ 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: '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 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 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[] {
@ -145,15 +262,260 @@ function getDocTags(doc: any): string[] {
function statusLabel(s: string) { return s === 'READY' ? '已完成' : s === 'PROCESSING' ? '处理中' : s === 'FAILED' ? '失败' : s }
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))
// ==================== ====================
/** 把分类树 + 扁平目录数组组装成 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) {
page.value = p
loading.value = true
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, filterFolder.value || undefined, sortInfo.value?.sortBy || undefined, sortInfo.value ? (sortInfo.value.descending ? 'desc' : 'asc') : undefined)
if (json.success) {
documents.value = (json.data || []).map((d: any) => ({ ...d, _enabled: d.enabled !== false }))
total.value = json.total || 0
@ -241,24 +603,90 @@ async function batchDisable() {
} 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=IDcategoryId= folderId=0categoryId=
const folderId = t.nodeType === 'folder' ? String(t.folderId ?? '0') : '0'
const categoryId = String(t.categoryId ?? '0')
const ids = moveDialog.value.ids
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()
</script>
<style scoped>
.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; }
/* 树节点 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>

262
frontend/src/views/DocUpload.vue

@ -5,6 +5,18 @@
<!-- 上传元信息 -->
<div class="upload-meta">
<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">
<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
@ -15,9 +27,19 @@
<!-- 上传模式 Tab -->
<t-tabs v-model="uploadMode">
<t-tab-panel value="file" label="📄 文件上传">
<t-upload :auto-upload="false" theme="file-flow" :before-upload="beforeFileUpload"
<t-upload ref="uploadRef" :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"
:size-limit="{ size: 50, unit: 'MB', message: '文件大小不能超过 50MB' }" />
:size-limit="{ size: 50, unit: 'MB', message: '文件大小不能超过 50MB' }">
<template #trigger>
<div class="upload-trigger-row">
<t-button variant="outline" :loading="folderUploading" @click.stop.prevent="pickFolder">📁 选择文件夹上传</t-button>
<t-button variant="outline" @click.stop.prevent="pickFile">📄 选择文件</t-button>
<span v-if="folderUploading" class="folder-progress">上传中 {{ folderProgress ?? 0 }}%</span>
</div>
</template>
</t-upload>
<!-- 独立的文件夹选择隐藏 input不随 t-upload trigger 打开 -->
<input ref="folderInput" type="file" webkitdirectory style="display:none;" @change="onFolderSelected" />
</t-tab-panel>
<t-tab-panel value="text" label="📝 文本">
<t-textarea v-model="textContent" placeholder="粘贴文本内容..." :autosize="{ minRows: 6, maxRows: 12 }" />
@ -35,10 +57,13 @@
</template>
<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, UploadInstanceFunctions } from 'tdesign-vue-next'
import { useCategoryStore } from '@/stores/category'
import { useDocumentStore } from '@/stores/document'
import { uploadFile, uploadMarkdown } from '@/api/upload'
import { uploadFile, uploadMarkdown, uploadJsonBasic, uploadFolder } from '@/api/upload'
import { getFolderTree } from '@/api/folder'
import { toast } from '@/utils/toast'
const categoryStore = useCategoryStore()
@ -48,6 +73,18 @@ const uploadMode = ref('file')
const uploadCategory = ref('')
const uploading = ref(false)
// t-upload trigger
const uploadRef = ref<UploadInstanceFunctions>()
// 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 tagInputValue = ref('')
@ -72,10 +109,23 @@ function handleTagBackspace() {
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()
fd.append('file', file)
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(','))
return fd
}
@ -87,41 +137,46 @@ function beforeFileUpload(file: File): boolean {
return true
}
async function handleFileUpload(req: { file: File; onProgress?: (e: { percent: number }) => void; onSuccess: () => void; onError: () => void }) {
/** 从 TDesign requestMethod 入参中取出原始 File(单文件为 UploadFile,多文件为数组) */
function pickRawFile(file: UploadFile | UploadFile[]): File | undefined {
const f = Array.isArray(file) ? file[0] : file
return f?.raw
}
async function handleFileUpload(file: UploadFile | UploadFile[]) {
try {
const fd = buildFormData(req.file)
await uploadFile(fd, (pct) => { if (req.onProgress) req.onProgress({ percent: pct }) })
req.onSuccess()
const raw = pickRawFile(file)
if (!raw) return { status: 'fail', error: '未获取到文件', response: {} }
const fd = buildFormData(raw, true)
await uploadFile(fd)
toast('上传成功,文档正在处理中', 'success')
documentStore.loadStats()
} catch (e: any) { req.onError(); toast('上传失败:' + e.message, 'error') }
return { status: 'success', response: {} }
} catch (e: any) { toast('上传失败:' + e.message, 'error'); return { status: 'fail', error: e.message, response: {} } }
}
async function handleMdUpload(req: { file: File; onProgress?: (e: { percent: number }) => void; onSuccess: () => void; onError: () => void }) {
async function handleMdUpload(file: UploadFile | UploadFile[]) {
try {
const fd = buildFormData(req.file)
await uploadMarkdown(fd, (pct) => { if (req.onProgress) req.onProgress({ percent: pct }) })
req.onSuccess()
const raw = pickRawFile(file)
if (!raw) return { status: 'fail', error: '未获取到文件', response: {} }
const fd = buildFormData(raw)
await uploadMarkdown(fd)
toast('Markdown 上传成功', 'success')
documentStore.loadStats()
} catch (e: any) { req.onError(); toast('上传失败:' + e.message, 'error') }
return { status: 'success', response: {} }
} catch (e: any) { toast('上传失败:' + e.message, 'error'); return { status: 'fail', error: e.message, response: {} } }
}
async function handleJsonUpload(req: { file: File; onProgress?: (e: { percent: number }) => void; onSuccess: () => void; onError: () => void }) {
// JSON 使 fetch
async function handleJsonUpload(file: UploadFile | UploadFile[]) {
try {
const fd = buildFormData(req.file)
const xhr = new XMLHttpRequest()
xhr.open('POST', '/upload/json/basic')
const token = localStorage.getItem('sb_token') || ''
if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token)
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable && req.onProgress) req.onProgress({ percent: Math.round((e.loaded / e.total) * 100) })
})
xhr.addEventListener('load', () => { req.onSuccess(); toast('JSON 上传成功', 'success'); documentStore.loadStats() })
xhr.addEventListener('error', () => { req.onError(); toast('上传失败', 'error') })
xhr.send(fd)
} catch (e: any) { req.onError(); toast('上传失败:' + e.message, 'error') }
const raw = pickRawFile(file)
if (!raw) return { status: 'fail', error: '未获取到文件', response: {} }
const fd = buildFormData(raw)
await uploadJsonBasic(fd)
toast('JSON 上传成功', 'success')
documentStore.loadStats()
return { status: 'success', response: {} }
} catch (e: any) { toast('上传失败:' + e.message, 'error'); return { status: 'fail', error: e.message, response: {} } }
}
async function uploadText() {
@ -139,9 +194,158 @@ async function uploadText() {
} catch (e: any) { toast('上传失败:' + e.message, 'error') }
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)
}
}
// ==================== trigger ====================
/** 选择文件:调用 t-upload 实例方法打开文件选择器 */
function pickFile() {
uploadRef.value?.triggerUpload?.()
}
/** 选择文件夹:触发独立的 webkitdirectory input */
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 }
// FormDatafiles + 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>
<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; }
.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); }
.upload-trigger-row { display: flex; align-items: center; gap: 8px; }
.folder-progress { font-size: 12px; color: var(--td-text-color-secondary); }
</style>

18
frontend/src/views/FaqManager.vue

@ -19,8 +19,8 @@
</div>
<!-- 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 }">
<span @click="toggleStatus(row)" style="cursor:pointer;" :style="{color: row.status === 'ENABLED' ? 'var(--td-success-color)' : 'var(--td-error-color)'}">
{{ row.status === 'ENABLED' ? '✅ 启用' : '❌ 禁用' }}
@ -32,7 +32,7 @@
<t-button size="small" variant="text" theme="danger" @click="removeFaq(row.id)">删除</t-button>
</t-space>
</template>
</t-table>
</BaseTable>
<!-- 新增/编辑抽屉富文本 Markdown 编辑器需宽幅用抽屉替代窄弹窗 -->
<FormDrawer
@ -133,15 +133,16 @@ async function handleUploadImg(files: File[], callback: (urls: string[]) => void
}
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: '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: '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 },
]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
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)}))])
@ -159,13 +160,14 @@ function flattenTree(tree: any[], prefix = ''): any[] {
}
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') }
finally { loading.value = false } }
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 onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;loadList()}
const debouncedSearch = debounce(() => { page.value = 1; loadList() })
/** 表单当前值快照,用于判断是否有未保存修改 */

18
frontend/src/views/FeedbackOps.vue

@ -11,8 +11,8 @@
</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 回复点赞或点踩后将在此展示。">
<template #feedbackType="{ row }">
<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-space>
</template>
</t-table>
</BaseTable>
<!-- 消息详情弹窗 -->
<t-dialog v-model:visible="detailModal.visible" header="会话消息详情" width="800px" :footer="false">
@ -133,15 +133,16 @@ const processedOptions = [
]
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: 'userQuestion', title: '用户问题', width: 200, ellipsis: true, cell: 'userQuestion' },
{ 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' },
]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
const canSubmit = computed(() => {
const m = faqModal.value
@ -201,6 +202,8 @@ async function loadList(p: number) {
page.value = p; loading.value = true
try {
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 (filterReasonCategory.value) params.reasonCategory = filterReasonCategory.value
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 onSortChange(s: any) { sortInfo.value = s && s.sortBy ? { sortBy: s.sortBy, descending: !!s.descending } : null; loadList(1) }
async function viewDetail(row: any) {
try {

8
frontend/src/views/LoginPage.vue

@ -14,7 +14,7 @@
<t-button type="submit" theme="primary" block size="large" :loading="loading"> </t-button>
</t-form-item>
</t-form>
<p class="login-error" v-if="errorMsg">{{ errorMsg }}</p>
<t-alert v-if="errorMsg" theme="error" :message="errorMsg" style="margin-top:12px;" />
</div>
<p class="login-footer">Powered by Spring AI + TDesign</p>
</div>
@ -23,7 +23,7 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { login } from '@/api/auth'
import { setToken, setRefreshToken, setUserInfo } from '@/utils/token'
import { setToken, setUserInfo, cleanupLegacyTokens } from '@/utils/token'
import { getCurrentUser } from '@/api/auth'
const emit = defineEmits<{ loginSuccess: [user: any] }>()
@ -43,7 +43,7 @@ async function handleLogin() {
const res = await login(form.username, form.password)
if (res.success && res.data) {
setToken(res.data.accessToken)
setRefreshToken(res.data.refreshToken)
cleanupLegacyTokens()
//
const userRes = await getCurrentUser()
if (userRes.success && userRes.data) {
@ -56,6 +56,7 @@ async function handleLogin() {
errorMsg.value = res.message || '登录失败'
}
} catch (e: any) {
// Error message
errorMsg.value = e.message || '登录失败'
}
loading.value = false
@ -73,6 +74,5 @@ async function handleLogin() {
}
.login-card h2 { text-align: center; margin-bottom: 6px; color: var(--color-text-primary); }
.login-subtitle { text-align: center; color: var(--color-text-tertiary); font-size: 13px; margin-bottom: 32px; }
.login-error { color: var(--color-error); font-size: 13px; text-align: center; margin-top: 8px; }
.login-footer { color: rgba(255,255,255,.6); font-size: 12px; margin-top: 24px; }
</style>

44
frontend/src/views/McpServerManager.vue

@ -3,31 +3,25 @@
<p class="desc-text">管理 MCP (Model Context Protocol) 服务端配置支持 SSE Stdio 两种传输方式</p>
<div class="toolbar">
<t-button theme="primary" size="small" @click="openAddModal">新建配置</t-button>
<t-button variant="outline" size="small" @click="refreshConnections" :disabled="refreshLoading">{{ refreshLoading?'刷新中...':'刷新连接' }}</t-button>
<t-button variant="outline" size="small" @click="refreshConnections" :loading="refreshLoading">刷新连接</t-button>
<t-button variant="outline" size="small" @click="load(currentPage)">刷新列表</t-button>
<div style="flex:1;" />
<span class="total-hint"> {{ total }} 条配置</span>
</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 #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 }">
<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)" :loading="testLoading[row.id]">测试</t-button>
<t-button size="small" variant="text" @click="openEditModal(row)">编辑</t-button>
<t-button size="small" variant="text" theme="danger" @click="remove(row.id,row.name)">删除</t-button>
</t-space>
</template>
</t-table>
<!-- 测试结果 -->
<div v-if="testResult.visible" class="test-toast" :style="{background:testResult.success?'var(--color-success-text)':'var(--color-error-text)'}">
<strong>{{ testResult.success?'连接成功':'连接失败' }}</strong>
<span v-if="testResult.message" class="test-msg">{{ testResult.message }}</span>
</div>
</BaseTable>
<!-- 编辑弹窗 -->
<t-dialog v-model:visible="showModal" :header="modalMode==='add'?'新建 MCP 服务配置':'编辑 MCP 服务配置'" width="600px" :footer="false">
@ -60,7 +54,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted } from 'vue'
import * as api from '@/api/mcp-server'
import { toast } from '@/utils/toast'
import { formatDate } from '@/utils/format'
@ -68,20 +62,21 @@ import { useConfirm } from '@/composables/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 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 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())
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 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 openAddModal(){modalMode.value='add';editId.value=null;form.value=createEmpty();showModal.value=true}
@ -106,13 +101,16 @@ async function save(){
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')}}
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}
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}}}
function showResult(r:any){if(testTimer)clearTimeout(testTimer);testResult.value={visible:true,success:r.success,message:r.message||''};testTimer=setTimeout(()=>{testResult.value.visible=false},5000)}
function showResult(r:any){if(r.success){toast('连接成功'+(r.message?':'+r.message:''),'success')}else{toast('连接失败:'+(r.message||'未知错误'),'error')}}
async function refreshConnections(){refreshLoading.value=true
try{const r=await api.refreshMcpServers();if(r.success){toast('连接已刷新','success');load(currentPage.value)}else toast(r.message||'刷新失败','error')}catch(e:any){toast('刷新失败:'+e.message,'error')}finally{refreshLoading.value=false}}
@ -120,4 +118,4 @@ async function refreshConnections(){refreshLoading.value=true
function getDisplayUrl(s:any){if(s.transport_type==='sse')return s.server_url||'-';let d=s.command||'';if(s.args)d+=' '+(Array.isArray(s.args)?s.args.join(' '):s.args);return d||'-'}
</script>
<style scoped>
.ellipsis{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;}.test-toast{position:fixed;bottom:20px;right:20px;padding:12px 20px;border-radius:8px;z-index:9999;color:var(--td-text-color-anti);max-width:400px;box-shadow:0 4px 12px rgba(0,0,0,.15);}.test-msg{font-size:12px;margin-top:4px;opacity:.9;display:block;}</style>
.ellipsis{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;}</style>

70
frontend/src/views/ModelConfigManager.vue

@ -35,13 +35,12 @@
<div v-if="fallbackChains.length > 0" class="fallback-section">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span style="font-size:13px;font-weight:600;color:var(--color-info-text-strong);">🔗 Fallback 按优先级排列</span>
<t-tag size="small" variant="light" theme="primary" style="cursor:pointer;" @click="showFallbackHelp = !showFallbackHelp"> 帮助</t-tag>
</div>
<div v-if="showFallbackHelp" class="fallback-help">
<div style="font-weight:600;color:var(--color-info-text-strong);margin-bottom:8px;">📖 Fallback 链使用说明</div>
<p>Fallback 链是<span style="color:var(--color-success-text-strong);font-weight:600;">高可用性机制</span>主模型故障时自动切换到备用模型</p>
<p> 勾选设为活跃配置启用 | 📊 调整优先级数值越大越优先| 🔀 拖拽列表项快速调整</p>
<t-button size="small" variant="text" @click="showFallbackHelp = !showFallbackHelp"> 帮助</t-button>
</div>
<t-alert v-if="showFallbackHelp" theme="info" title="📖 Fallback 链使用说明" style="margin:0 0 8px;">
<p style="margin:4px 0;">Fallback 链是<span style="color:var(--color-success-text-strong);font-weight:600;">高可用性机制</span>主模型故障时自动切换到备用模型</p>
<p style="margin:4px 0;"> 勾选设为活跃配置启用 | 📊 调整优先级数值越大越优先| 🔀 拖拽列表项快速调整</p>
</t-alert>
<div v-for="chain in fallbackChains" :key="chain.appType" style="margin-bottom:8px;">
<div style="font-size:12px;color:var(--color-info-text-strong);font-weight:600;">{{ getAppTypeLabel(chain.appType) }}</div>
<div v-for="(item, idx) in chain.configs" :key="item.id"
@ -67,10 +66,9 @@
</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 }"
@page-change="onPageChange">
@page-change="onPageChange" @sort-change="onSortChange">
<template #app_type="{ row }">
<t-tag size="small" variant="light" theme="primary">{{ getAppTypeLabel(row.app_type) }}</t-tag>
</template>
@ -97,24 +95,14 @@
<template #op="{ row }">
<t-space :size="4">
<t-button size="small" variant="text" @click="openEditModal(row)" title="编辑"></t-button>
<t-button size="small" variant="text" @click="testConnection(row)" :disabled="testLoading[row.id]" title="测试连接">{{ testLoading[row.id] ? '⏳' : '🔌' }}</t-button>
<t-button size="small" variant="text" @click="testConnection(row)" :loading="testLoading[row.id]" title="测试连接">测试</t-button>
<t-button size="small" variant="text" @click="duplicateConfig(row)" title="复制">📋</t-button>
<t-button v-show="!row.is_active" size="small" variant="text" theme="primary" @click="activate(row.id)" title="激活"></t-button>
<t-button v-show="row.is_active" size="small" variant="text" @click="deactivateConfig(row.id)" 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>
</template>
</t-table>
</div>
<!-- F1: 测试结果提示 -->
<div v-if="testResult.visible" class="test-result-toast"
:style="{ position: 'fixed', bottom: '20px', right: '20px', padding: '12px 20px', borderRadius: '8px', zIndex: 9999, color: 'white', maxWidth: '400px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
background: testResult.success ? 'var(--color-success-text)' : 'var(--color-error-text)' }">
<div style="font-weight:600;">{{ testResult.success ? '✅ 连接成功' : '❌ 连接失败' }}</div>
<div style="font-size:12px;margin-top:4px;">延迟{{ testResult.latencyMs }}ms<span v-if="testResult.dimensions"> · 维度{{ testResult.dimensions }}</span></div>
<div v-if="testResult.errorMessage" style="font-size:11px;margin-top:4px;opacity:0.9;">{{ testResult.errorMessage }}</div>
</div>
</BaseTable>
<!-- ==================== 编辑/新建弹窗 ==================== -->
<t-dialog
@ -170,9 +158,9 @@
@click="editModal.form.model_name = ''; modelDropdownOpen = false; updateNameSuggestion()" style="color:var(--color-text-tertiary);"></t-button>
</template>
</t-input>
<t-button variant="outline" size="small" @click="fetchModelsFromApi" :disabled="fetchLoading"
<t-button variant="outline" size="small" @click="fetchModelsFromApi" :loading="fetchLoading"
style="border-color:var(--td-brand-color);color:var(--td-brand-color);white-space:nowrap;">
{{ fetchLoading ? '⏳ 获取中...' : '🔍 获取模型' }}
获取模型
</t-button>
</t-space>
<!-- 自定义下拉列表 -->
@ -266,8 +254,8 @@
<div class="dialog-footer" style="gap:10px;margin-top:20px;">
<t-button variant="outline" @click="editModal.visible = false">取消</t-button>
<t-button v-if="editModal.mode === 'edit'" variant="outline" @click="testConnectionById(editModal.editId!)"
:disabled="testLoading[editModal.editId!]" style="border-color:var(--td-brand-color);color:var(--td-brand-color);">
{{ testLoading[editModal.editId!] ? '⏳ 测试中...' : '🔌 测试连接' }}
:loading="testLoading[editModal.editId!]" style="border-color:var(--td-brand-color);color:var(--td-brand-color);">
测试连接
</t-button>
<t-button theme="primary" @click="saveConfig">💾 保存</t-button>
</div>
@ -300,8 +288,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import * as api from '@/api/model-config'
import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm'
@ -355,14 +342,15 @@ const importConflictOptions = [
// ==================== ====================
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: 'api_key', title: 'API Key', width: 180 },
{ colKey: 'status', title: '状态', width: 120 },
{ colKey: 'status', title: '状态', width: 120, sorter: true },
{ colKey: 'op', title: '操作', width: 240 },
]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
// ==================== ====================
const configs = ref<any[]>([])
@ -397,8 +385,6 @@ let healthTimer: any = null
//
const testLoading = ref<Record<string, boolean>>({})
const testResult = reactive({ visible: false, success: false, latencyMs: 0, dimensions: null as any, errorMessage: null as any })
let testResultTimer: any = null
// Fallback
const fallbackChains = ref<any[]>([])
@ -514,7 +500,7 @@ async function load(p = currentPage.value) {
tableLoading.value = true
currentPage.value = p
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) {
configs.value = json.data || []
total.value = json.total || 0
@ -525,6 +511,7 @@ async function load(p = currentPage.value) {
}
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() {
@ -645,9 +632,14 @@ async function testConnectionById(id: string) {
}
function showTestResult(r: any) {
if (testResultTimer) clearTimeout(testResultTimer)
testResult.visible = true; testResult.success = r.success; testResult.latencyMs = r.latencyMs || 0; testResult.dimensions = r.dimensions || null; testResult.errorMessage = r.errorMessage || r.message || null
testResultTimer = setTimeout(() => { testResult.visible = false }, 5000)
if (r.success) {
let msg = '连接成功'
if (r.latencyMs) msg += '(延迟 ' + r.latencyMs + 'ms' + (r.dimensions ? ' · 维度 ' + r.dimensions : '') + ')'
toast(msg, 'success')
} else {
const reason = r.errorMessage || r.message
toast('连接失败' + (reason ? ':' + reason : ''), 'error')
}
}
// ==================== /// ====================
@ -709,11 +701,9 @@ function getProviderBaseUrl(p: string) { return PROVIDER_BASE_URLS[p] || '' }
// ==================== ====================
onMounted(() => { load(); healthTimer = setInterval(loadHealthStatus, 30000); document.addEventListener('click', onDocumentClick, true) })
onUnmounted(() => { clearInterval(healthTimer); clearTimeout(testResultTimer); document.removeEventListener('click', onDocumentClick, true) })
onUnmounted(() => { clearInterval(healthTimer); document.removeEventListener('click', onDocumentClick, true) })
</script>
<style scoped>
.fallback-section { margin:12px 0; padding:12px; background:var(--color-info-bg); border:1px solid var(--color-info-border); border-radius:8px; }
.fallback-help { margin-bottom:12px; padding:12px; background:white; border:1px solid var(--color-info-border); border-radius:6px; font-size:12px; line-height:1.6; color:var(--color-text-primary); }
.fallback-help p { margin:4px 0; }
</style>

42
frontend/src/views/PipelineFlow.vue

@ -26,13 +26,11 @@
<!-- 流程图 -->
<div style="margin-top:16px;">
<div v-if="error" class="pipeline-error">
<p> 图表渲染失败: {{ error }}</p>
<t-button variant="outline" size="small" @click="retry">重试</t-button>
</div>
<div v-else-if="!svg" class="pipeline-loading">
<p> 正在生成流程图...</p>
</div>
<t-alert v-if="error" theme="error">
<template #default>图表渲染失败{{ error }}</template>
<template #operation><t-button variant="text" size="small" @click="retry">重试</t-button></template>
</t-alert>
<t-loading v-else-if="!svg" text="正在生成流程图..." style="text-align:center;padding:40px 0;" />
<div v-else class="pipeline-diagram" v-html="svg" />
</div>
</t-card>
@ -40,7 +38,7 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import { toast } from '@/utils/toast'
import { DownloadIcon } from 'tdesign-icons-vue-next'
import mermaid from 'mermaid'
import { palette, paletteBg, paletteBorder } from '@/utils/palette'
@ -78,7 +76,7 @@ mermaid.initialize({
// Mermaid DSL
// : []=, {}=, subgraph=
// %%graph-meta: { updated: "2026-08-04", basedOn: "ChatPipeline v3, RagPipeline v2, AssistantApp v2", mermaidVersion: "flowchart-v2" }
// %%graph-meta: { updated: "2026-08-27", basedOn: "ChatPipeline v3, RagPipeline v2, AssistantApp v2", mermaidVersion: "flowchart-v2" }
const GRAPH_DEFINITION = `
flowchart TD
A["<b>用户请求</b><br/>message + roleId + accountId + chatId"]
@ -97,7 +95,7 @@ flowchart TD
G -- "FAQ<br/>confidence ≧ 0.8" --> H["<b>FaqMatchEngine</b><br/>三级匹配策略<br/>精确 → 关键词 → 向量语义"]
G -- "CHITCHAT<br/>confidence ≧ 0.6" --> I["<b>模式: 纯对话</b><br/>跳过知识库检索<br/>不注入资料块"]
G -- "CHITCHAT<br/>confidence ≧ 0.6" --> CHK["<b>闲聊前 FAQ 精准匹配</b><br/>先试 FaqMatchEngine<br/>命中则短路返回"]
G -- "RAG / 降级<br/>其余情况" --> J["<b>RagPipeline.retrieve</b><br/>RAG 检索流水线入口"]
@ -111,6 +109,8 @@ flowchart TD
M --> S["<b>4. 构建资料块</b><br/>拼接检索文档<br/>注入 system prompt 末尾"]
end
CHK -. "❌ 未命中 → 纯对话" .-> I["<b>模式: 纯对话</b><br/>跳过知识库检索<br/>不注入资料块"]
CHK -- "✅ 命中标准答案" --> T
I --> T
S --> T
E --> T
@ -179,7 +179,7 @@ function retry() {
async function exportImage() {
const svgEl = document.querySelector('.pipeline-diagram svg') as SVGSVGElement | null
if (!svgEl) {
MessagePlugin.warning('流程图尚未渲染完成,请稍后再试')
toast('流程图尚未渲染完成,请稍后再试', 'warning')
return
}
@ -229,7 +229,7 @@ async function exportImage() {
link.download = `ai-执行链-${timestamp}.svg`
link.click()
URL.revokeObjectURL(svgUrl)
MessagePlugin.warning('PNG 导出受限,已降级为 SVG 矢量图下载')
toast('PNG 导出受限,已降级为 SVG 矢量图下载', 'warning')
}
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' })
@ -246,7 +246,7 @@ async function exportImage() {
const ctx = canvas.getContext('2d')
if (!ctx) {
URL.revokeObjectURL(url)
MessagePlugin.error('Canvas 上下文创建失败')
toast('Canvas 上下文创建失败', 'error')
return
}
// /线
@ -260,7 +260,7 @@ async function exportImage() {
link.href = pngUrl
link.download = `ai-执行链-${timestamp}.png`
link.click()
MessagePlugin.success('图片导出成功')
toast('图片导出成功', 'success')
} catch (e: any) {
URL.revokeObjectURL(url)
console.error('Canvas 导出失败,降级为 SVG:', e)
@ -276,7 +276,7 @@ async function exportImage() {
} catch (e: any) {
if (url) URL.revokeObjectURL(url)
console.error('导出图片失败:', e)
MessagePlugin.error(e.message || '导出失败')
toast(e.message || '导出失败', 'error')
}
}
@ -325,16 +325,4 @@ onMounted(() => {
display: block;
margin: 0 auto;
}
.pipeline-loading {
text-align: center;
padding: 40px;
color: var(--color-text-tertiary);
font-size: 14px;
}
.pipeline-error {
text-align: center;
padding: 24px;
color: var(--color-error);
font-size: 14px;
}
</style>

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.faqRate }}</span><span class="stat-label">FAQ 命中率</span></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">
@ -32,10 +32,10 @@
</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"
: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 }">
<span v-if="row.roleName">{{ row.roleName }}</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="openTimeline(row)">时间线</t-button>
</template>
</t-table>
</BaseTable>
<!-- 详情抽屉 -->
<t-drawer v-model:visible="detailVisible" size="large" header="调用详情" :footer="false">
@ -97,7 +97,7 @@
</t-tab-panel>
<t-tab-panel value="rag" label="RAG 资料">
<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>
<pre class="full-prompt">{{ detail.ragContext || '(无 RAG 资料)' }}</pre>
</t-tab-panel>
@ -111,18 +111,18 @@
</div>
</t-tab-panel>
<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 #error="{ row }">
<t-tag v-if="row.error" size="small" variant="light" theme="danger">失败</t-tag>
<span v-else class="muted">-</span>
</template>
</t-table>
</BaseTable>
<t-empty v-else description="本次调用未触发 MCP 工具" />
</t-tab-panel>
<t-tab-panel value="context" label="对话上下文">
<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-tab-panel>
</t-tabs>
@ -263,17 +263,18 @@ const selectedRowKeys = ref<string[]>([])
const columns = [
{ 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: 'op', title: '操作', width: 150, fixed: 'right' },
]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
// ===== =====
const groupBy = ref<'role' | 'model'>('role')
@ -281,9 +282,9 @@ const stats = ref<any[]>([])
const statsLoading = ref(false)
const statsColumns = [
{ 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 = [
{ colKey: 'title', title: '标题', 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 = [
{ colKey: 'tool', title: '工具', width: 150, ellipsis: true },
{ colKey: 'tool', title: '工具', width: 150, ellipsis: true, sorter: true },
{ colKey: 'input', 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 },
]
@ -434,6 +435,8 @@ async function loadList() {
keyword: filterKeyword.value || undefined,
apiKeyId: filterApiKeyId.value || undefined,
errorType: filterErrorType.value || undefined,
sortField: sortInfo.value?.sortBy || undefined,
sortOrder: sortInfo.value ? (sortInfo.value.descending ? 'desc' : 'asc') : undefined,
})
if (r.success) {
traces.value = r.data?.records || r.data || []
@ -454,6 +457,7 @@ function onPageChange(info: { current: number; pageSize: number }) {
selectedRowKeys.value = []
loadList()
}
function onSortChange(s: any) { sortInfo.value = s && s.sortBy ? { sortBy: s.sortBy, descending: !!s.descending } : null; loadList() }
function onFilterChange() {
page.value = 1

20
frontend/src/views/SensitiveWordManager.vue

@ -12,8 +12,8 @@
<span class="total-hint"> {{ total }} </span>
</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 }">
<t-tag size="small" :style="{ background: catColor(row.category) }" variant="light">{{ catLabel(row.category) }}</t-tag>
</template>
@ -29,7 +29,7 @@
<t-button size="small" variant="text" theme="danger" @click="doDelete(row)">删除</t-button>
</t-space>
</template>
</t-table>
</BaseTable>
<!-- 新增/编辑弹窗 -->
<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 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: '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 },
]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
onMounted(() => loadList())
@ -129,7 +130,7 @@ async function loadList() {
loading.value = true
try {
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) {
const list = r.data?.records || r.data || []
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
loadList()
}
function onSortChange(s: any) { sortInfo.value = s && s.sortBy ? { sortBy: s.sortBy, descending: !!s.descending } : null; loadList() }
function onFilterChange() {
page.value = 1

19
frontend/src/views/UserManager.vue

@ -9,8 +9,8 @@
<t-button theme="primary" size="small" @click="openCreate">+ 新建用户</t-button>
</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 }">
<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>
@ -26,7 +26,7 @@
<t-button size="small" variant="text" :theme="row.enabled?'warning':'success'" @click="handleToggle(row)">{{ row.enabled?'禁用':'启用' }}</t-button>
</t-space>
</template>
</t-table>
</BaseTable>
<!-- 新建/编辑弹窗 -->
<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 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 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 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()})
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)}}
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 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>
<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}">
<t-tag v-for="ev in getEvents(row)" :key="ev" size="small" variant="light" style="margin-right:4px;">{{ ev }}</t-tag>
</template>
@ -18,7 +18,7 @@
<t-button size="small" variant="text" theme="danger" @click="doDelete(row.id)">删除</t-button>
</t-space>
</template>
</t-table>
</BaseTable>
<t-dialog v-model:visible="showFormDialog" :header="editingId?'编辑 Webhook':'新建 Webhook'" width="520px" :footer="false">
<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 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())
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
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 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 openEditDialog(w:any){editingId.value=w.id;form.value={name:w.name||'',url:w.url||'',events:getEvents(w),enabled:w.enabled};showFormDialog.value=true}

65
pom.xml

@ -5,7 +5,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.4</version>
<version>3.5.8</version>
<relativePath/>
</parent>
<groupId>com.cdw</groupId>
@ -14,7 +14,10 @@
<name>智能客服</name>
<properties>
<java.version>17</java.version>
<spring-ai-alibaba.version>1.0.0.4</spring-ai-alibaba.version>
<!-- Spring AI BOM 版本(统一管理 org.springframework.ai 全部依赖) -->
<spring-ai.version>1.1.2</spring-ai.version>
<!-- Spring AI Alibaba 版本(1.1.2.2 官方基线 = Boot 3.5.8 + Spring AI 1.1.2) -->
<spring-ai-alibaba.version>1.1.2.2</spring-ai-alibaba.version>
<!-- 设置为 true 可跳过前端 SDK 构建,加快后端编译:mvn compile -Dskip.frontend=true -->
<skip.frontend>false</skip.frontend>
</properties>
@ -44,10 +47,12 @@
<dependencyManagement>
<dependencies>
<!-- Spring AI BOM:统一管理所有 Spring AI 依赖版本 -->
<!-- 注意:不导入 spring-ai-alibaba-bom,因其 dependencyManagement 不包含
spring-ai-alibaba-starter-dashscope,导入后仍需显式写版本号,无收益 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.1</version>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
@ -66,6 +71,7 @@
</dependency>
<!-- Spring AI Alibaba DashScope(通义千问)- 新版 starter,替代老版 spring-ai-alibaba-starter -->
<!-- 必须显式写版本号:spring-ai-alibaba-bom 并不管理本模块 -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
@ -79,7 +85,13 @@
</dependency>
<!-- Spring AI MCP Client(Model Context Protocol 客户端) -->
<!-- 排除旧版 MCP SDK 0.10.0,显式引入新版 0.18.3 修复 SSE 400 + 超时问题 -->
<!-- 必须排除传递引入的 MCP SDK 并显式锁定 0.18.3,切勿删除:
Spring AI 1.1.2 的 spring-ai-mcp 仍锁定 MCP SDK 0.17.0,而 0.17.0 的 JsonMapper
包名是 io.modelcontextprotocol.json.jackson(无 jackson2),
本项目 McpClientManager 使用的是 0.18.0 起才有的 ...json.jackson2.JacksonMcpJsonMapper,
不锁版本会直接编译失败。0.18.3 已修复 SSE 400 + 超时问题。
安全性:spring-ai-mcp 1.1.2 仅引用 McpSyncClient/McpAsyncClient/McpClient/
McpTransportContext/McpSchema/Assert,这些类 0.18.3 均存在。 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
@ -91,7 +103,7 @@
</exclusions>
</dependency>
<!-- 显式引入新版 MCP SDK(覆盖 spring-ai-mcp 的旧版 0.10.0) -->
<!-- 显式引入新版 MCP SDK(覆盖 spring-ai-mcp 传递的 0.17.0) -->
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
@ -102,7 +114,7 @@
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.37</version>
<version>5.8.41</version>
</dependency>
<!-- SFTP 客户端(附件上传到 SFTP 服务器) -->
<dependency>
@ -114,28 +126,24 @@
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.4.0</version>
<version>4.5.0</version>
</dependency>
<!-- 支持结构化输出 -->
<!-- 结构化输出:victools jsonschema-generator 已由 spring-ai-model 传递引入(4.38.0),
项目不再显式声明(显式声明曾导致 4.38.0/4.37.0 版本混用) -->
<!-- Token 分词:OverlapTokenTextSplitter 直接使用(与 Spring AI TokenTextSplitter 同库同版本) -->
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-generator</artifactId>
<version>4.38.0</version>
</dependency>
<!-- 支持文件会话记忆持久化的序列化 -->
<dependency>
<groupId>com.esotericsoftware</groupId>
<artifactId>kryo</artifactId>
<version>5.6.2</version>
<groupId>com.knuddels</groupId>
<artifactId>jtokkit</artifactId>
<version>1.1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-markdown-document-reader</artifactId>
</dependency>
<!-- Lombok 版本交由 Spring Boot 父 POM 托管(3.5.8 管理 1.18.42) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.36</version>
<optional>true</optional>
</dependency>
<dependency>
@ -172,31 +180,31 @@
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
<version>0.12.7</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<version>0.12.7</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<version>0.12.7</version>
<scope>runtime</scope>
</dependency>
<!-- Spring AI ToolCallingAutoConfiguration 依赖 spring-security-oauth2-client,补充该依赖避免 ClassNotFoundException -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
</dependency>
<!-- 说明:spring-security-oauth2-client 补丁依赖已移除。
Spring AI 1.0.1 的 ToolCallingAutoConfiguration 曾硬引用 oauth2-client 类导致
ClassNotFoundException;1.1.2 已无该耦合(已核对字节码常量池零 oauth2 引用),
且 ToolCallingManager Bean 仍由 spring-ai-autoconfigure-model-tool 提供。
若启动仍报相关类缺失,再按需加回。 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.12</version>
<version>3.5.14</version>
</dependency>
</dependencies>
@ -238,7 +246,8 @@
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.36</version>
<!-- 使用 Boot 父 POM 托管的 ${lombok.version},与运行时版本保持一致 -->
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>

5
src/main/java/com/wok/supportbot/SupportBotApplication.java

@ -8,8 +8,9 @@ import org.springframework.transaction.annotation.EnableTransactionManagement;
/**
* 主启动类
* spring-security-oauth2-client 依赖已通过 pom.xml 补充
* 确保 ToolCallingAutoConfiguration 不会抛出 ClassNotFoundException
* <p>
* ChatModel / EmbeddingModel / VectorStore 均由项目内工厂手动构建 ChatModelFactory
* EmbeddingModelFactoryPgVectorStoreConfig不使用 Spring AI 的自动配置
*/
@SpringBootApplication
@EnableScheduling

43
src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java

@ -1,43 +0,0 @@
package com.wok.supportbot.advisor;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.AdvisorChain;
import org.springframework.ai.chat.client.advisor.api.BaseAdvisor;
import java.util.HashMap;
import java.util.Map;
/**
* 自定义 Re2 Advisor适配 Spring AI 1.0.1 Advisor API
* 可提高大型语言模型的推理能力
*/
public class ReReadingAdvisor implements BaseAdvisor {
@Override
public String getName() {
return this.getClass().getSimpleName();
}
@Override
public int getOrder() {
return 0;
}
@Override
public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
// Re2 策略将用户问题重复一次以增强推理
// 通过 context 传递原始查询 prompt 中追加重复指令
Map<String, Object> newContext = new HashMap<>(request.context());
newContext.put("re2_enabled", true);
return ChatClientRequest.builder()
.prompt(request.prompt())
.context(newContext)
.build();
}
@Override
public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) {
return response;
}
}

13
src/main/java/com/wok/supportbot/app/AssistantApp.java

@ -44,7 +44,6 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
@ -519,11 +518,9 @@ public class AssistantApp {
});
// 聚合所有分片用于埋点 doFinally 时取完整回复文本
StringBuilder aggregated = new StringBuilder();
// 首片标记第一片 delta 需带 role=assistant后续片仅含 content
AtomicBoolean first = new AtomicBoolean(true);
return preserveTrailingWhitespace(rawStream)
.doOnNext(aggregated::append)
.map(chunk -> buildOpenAiChunk(completionId, model, created, chunk, first.getAndSet(false), null))
.map(chunk -> buildOpenAiChunk(completionId, model, created, chunk, false, null))
.doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY))
.doOnError(e -> {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
@ -543,13 +540,17 @@ public class AssistantApp {
usage != null ? usage.getTotalTokens() : null,
events));
})
// 首片 role=assistant content在流订阅时立即发出确保 SSE 响应头/首字节及时 flush
// 推理模型 doubao-seed思考阶段 delta.content 为空 preserveTrailingWhitespace 吞掉
// 若不提前发首片思考阶段将无任何字节输出前端等待首字节会触发 60s 超时
.startWith(buildOpenAiChunk(completionId, model, created, "", true, null))
// 流正常结束时追加 finish_reason=stop chunk [DONE]
.concatWith(Flux.just(
buildOpenAiChunk(completionId, model, created, "", false, "stop"),
"[DONE]"))
// 错误兜底脱敏错误信息避免泄露内部细节role 是否出现取决于此前是否已发出过内容片
// 错误兜底脱敏错误信息避免泄露内部细节首片 role 已提前发出此处不再带 role
.onErrorResume(e -> openAiFallbackStream(completionId, model, created,
"抱歉,AI 服务调用失败:" + maskError(e.getMessage()), first.get()));
"抱歉,AI 服务调用失败:" + maskError(e.getMessage()), false));
}
/**

32
src/main/java/com/wok/supportbot/app/ChatPipeline.java

@ -66,7 +66,7 @@ public class ChatPipeline {
* <li>未启用 RAG普通对话 / 严格隔离下 KB 拒绝 用原始 message基础 system</li>
* <li>寒暄/闲聊IntentRouter 或寒暄词命中 同上跳过 KB 检索</li>
* <li>FAQ 命中 直接返回标准答案不调用 ChatClient</li>
* <li>RAG 生成 资料块注入 system重写后查询作为 user 消息</li>
* <li>RAG 生成 资料块注入 system原始 message 作为 user 消息重写查询仅用于检索</li>
* </ul>
*
* @param ctx 对话上下文
@ -85,28 +85,40 @@ public class ChatPipeline {
// 意图路由先用 IntentRouter 做细粒度分类
IntentRouter.IntentResult intent = routeIntent(ctx.message());
// FAQ 高置信度优先匹配标准答案未命中时降级到 RAG 检索避免知识库中已有答案却返回兜底提示
// FAQ 高置信度优先匹配标准答案未命中时降级到 RAG 检索避免知识库中已有答案却返回兜底提示
// 若此处已干净跑完完整 FAQ 三级匹配仍未命中进入 RAG 检索时跳过重复的 FAQ 匹配避免同一请求两次 FAQ 语义 embedding
boolean faqSkippableInRetrieve = false;
if (intent != null && "FAQ".equals(intent.getIntent())
&& intent.getConfidence() >= FAQ_HIGH_CONFIDENCE_THRESHOLD) {
Optional<FaqMatchResult> faqMatch = ragPipeline.tryFaqMatchResult(ctx.message());
if (faqMatch.isPresent()) {
RagPipeline.FaqMatchOutcome faqOutcome = ragPipeline.tryFaqMatchClean(ctx.message(), ctx.categoryIds());
if (faqOutcome.result().isPresent()) {
log.info("FAQ 高置信({}),命中标准答案: chatId={}", intent.getConfidence(), ctx.chatId());
Optional<String> faqAnswer = Optional.ofNullable(faqMatch.get().getFaq().getAnswer());
Optional<String> faqAnswer = Optional.ofNullable(faqOutcome.result().get().getFaq().getAnswer());
return new ChatRequest(ctx, ctx.message(), baseSystem, faqAnswer,
globalPrompt, null, null, "FAQ", null, null, faqMatch.get());
globalPrompt, null, null, "FAQ", null, null, faqOutcome.result().get());
}
log.info("FAQ 高置信({}) 未命中标准答案,降级到 RAG 检索: chatId={}", intent.getConfidence(), ctx.chatId());
// 仅当第一次 FAQ 匹配干净完成才允许后续检索跳过第二次 FAQ异常降级的 miss 不跳过避免误跳
faqSkippableInRetrieve = faqOutcome.completedCleanly();
}
// 寒暄/闲聊IntentRouter 判定 CHITCHAT 高置信跳过 KB 检索
if (intent != null && "CHITCHAT".equals(intent.getIntent())
&& intent.getConfidence() >= CHITCHAT_CONFIDENCE_THRESHOLD) {
// 闲聊前先尝试 FAQ 精准匹配避免"你是谁"等被配置成 FAQ 后命中不了
Optional<FaqMatchResult> faqMatch = ragPipeline.tryFaqMatchResult(ctx.message(), ctx.categoryIds());
if (faqMatch.isPresent()) {
log.info("闲聊意图但 FAQ 命中标准答案: chatId={}, matchType={}", ctx.chatId(), faqMatch.get().getMatchType());
return new ChatRequest(ctx, ctx.message(), baseSystem,
Optional.ofNullable(faqMatch.get().getFaq().getAnswer()),
globalPrompt, null, null, "FAQ", null, null, faqMatch.get());
}
return new ChatRequest(ctx, ctx.message(), baseSystem, Optional.empty(),
globalPrompt, null, null, "CHITCHAT", null, null, null);
}
// RAG 检索 FAQ 优先匹配
RagContext rag = ragPipeline.retrieve(ctx);
// RAG 检索 FAQ 优先匹配FAQ 高置信已完整匹配过则跳过二次 FAQ
RagContext rag = ragPipeline.retrieve(ctx, faqSkippableInRetrieve);
// 记录 RAG 检索日志到 rag_hit_log 供知识库分析看板使用
if (!rag.faqHit() && rag.documents() != null && !rag.documents().isEmpty()) {
@ -128,9 +140,9 @@ public class ChatPipeline {
globalPrompt, null, null, "FAQ", null, null, rag.faqMatchResult());
}
// RAG 生成资料块注入 system重写后查询作为 user 消息
// RAG 生成资料块注入 system原始 message 作为 user 消息重写查询仅用于检索
String finalSystem = baseSystem + ragPipeline.buildRagContextBlock(rag.contextText());
return new ChatRequest(ctx, rag.rewrittenQuery(), finalSystem, Optional.empty(),
return new ChatRequest(ctx, ctx.message(), finalSystem, Optional.empty(),
globalPrompt, rag.contextText(), rag.documents() != null ? rag.documents().size() : 0, "RAG",
rag.searchMode(), rag.documents(), null);
}

2
src/main/java/com/wok/supportbot/app/ChatRequest.java

@ -15,7 +15,7 @@ import java.util.Optional;
* 字段含义
* <ul>
* <li>{@link #faqAnswer()}FAQ 命中时直接返回标准答案跳过 ChatClient 调用</li>
* <li>{@link #finalMessage()}传给模型的用户消息RAG 场景为重写后的查询否则为原始 message</li>
* <li>{@link #finalMessage()}传给模型的用户消息始终为原始 message查询重写仅用于检索</li>
* <li>{@link #finalSystemPrompt()}传给模型的系统提示词角色人设 + RAG 资料块可为空</li>
* <li>{@link #globalPrompt()}全局提示词快照来自 system_config.ai_system_prompt可为空</li>
* <li>{@link #ragContextText()}RAG 资料块文本未注入时为空/null</li>

5
src/main/java/com/wok/supportbot/app/ChatResult.java

@ -10,8 +10,9 @@ import java.util.List;
* 除了回答文本还携带本次触发的 MCP 工具调用事件让同步对话也能像流式对话一样
* 展示工具调用过程原仅流式 {@code appendMcpToolEvents} 追加事件同步路径无事件
* <p>
* 新增 {@code suggestions} 字段AI 推荐问题列表suggest-message-list
* {@link SuggestionResponseParser} LLM 输出中分离 LLM 路径FAQ/熔断为空列表
* 新增 {@code suggestions} 字段AI 推荐问题列表suggest-message-list
* 主链路AssistantApp / ChatPipeline恒为空列表推荐问题改由 {@link SuggestionGenerator}
* AI 回复结束后按需异步生成 LLM 路径FAQ/熔断亦为空
*
* @param text AI 回答文本
* @param mcpEvents 本次触发的 MCP 工具调用事件无调用时为空列表

31
src/main/java/com/wok/supportbot/app/SuggestionGenerator.java

@ -9,6 +9,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@ -110,16 +111,15 @@ public class SuggestionGenerator {
String historyText = formatHistory(history);
String prompt = buildPrompt(historyText);
String raw = getChatClient().prompt()
// 标准结构化输出BeanOutputConverter 自动把 JSON Schema 指令追加进 prompt
// 并把模型返回的 JSON 数组反序列化为 List<String>可兼容 ```json 代码块包裹
List<String> raw = getChatClient().prompt()
.system(prompt)
.user("请根据历史对话生成推荐问题")
.call()
.chatResponse()
.getResult()
.getOutput()
.getText();
.entity(new ParameterizedTypeReference<List<String>>() {});
List<String> suggestions = SuggestionResponseParser.parseSuggestionsJson(raw.strip());
List<String> suggestions = normalize(raw);
log.info("推荐问题生成成功: chatId={}, count={}", chatId, suggestions.size());
return suggestions;
} catch (Exception e) {
@ -128,6 +128,21 @@ public class SuggestionGenerator {
}
}
/**
* 规范化模型输出过滤空白项去首尾空白去重最多保留 3
*/
private List<String> normalize(List<String> raw) {
if (raw == null) {
return Collections.emptyList();
}
return raw.stream()
.filter(StringUtils::hasText)
.map(String::strip)
.distinct()
.limit(3)
.collect(Collectors.toList());
}
/**
* 格式化历史消息为纯文本上下文
*/
@ -171,8 +186,8 @@ public class SuggestionGenerator {
"输出要求:\n" +
"1. 问题必须与对话主题相关,引导用户深入咨询\n" +
"2. 每条问题用中文,不超过 30 个字\n" +
"3. 只输出 JSON 数组格式,例如:[\"问题1\", \"问题2\", \"问题3\"]\n" +
"4. 不要输出任何其他解释、markdown 代码块或序号";
"3. 每条问题是一个独立、可直接点击发送的完整问句\n" +
"4. 不要输出任何解释、markdown 代码块或序号(输出格式由系统统一约束)";
}
/**

285
src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java

@ -1,285 +0,0 @@
package com.wok.supportbot.app;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.cache.SuggestionCache;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* 建议问题解析器 LLM 原始输出中分离 answer suggestions
* <p>
* 哨兵标记为 <code>___SUGGESTIONS___</code>之后为 JSON 字符串数组
* 支持同步路径直接分割完整文本和流式路径滑动窗口检测哨兵
*/
@Slf4j
public final class SuggestionResponseParser {
/** 主哨兵:严格匹配提示词要求的下划线分隔标记 */
private static final String SENTINEL = "___SUGGESTIONS___";
/** 兼容哨兵:模型偶尔不按指令输出时的大写变形 */
private static final String LOOSE_SENTINEL = "SUGGESTIONS";
private static final ObjectMapper objectMapper = new ObjectMapper();
private SuggestionResponseParser() {
}
/**
* 查找最佳哨兵位置优先严格哨兵其次兼容哨兵
* <p>
* 兼容哨兵必须后面紧跟 JSON 数组特征'[' 或空白 + '['
* 避免正文中出现 "SUGGESTIONS" 普通单词时误触发
*
* @param text 要搜索的文本
* @return 哨兵起始位置未找到返回 -1
*/
private static int findSentinelIndex(String text) {
int strictIdx = text.lastIndexOf(SENTINEL);
if (strictIdx >= 0) {
return strictIdx;
}
// 兼容模式从后往前找大写 SUGGESTIONS且后续需连接 JSON 数组
int idx = text.lastIndexOf(LOOSE_SENTINEL);
while (idx >= 0) {
int after = idx + LOOSE_SENTINEL.length();
if (after < text.length()) {
char c = text.charAt(after);
// 允许 SUGGESTIONS[...]SUGGESTIONS [...]SUGGESTIONS:\n[...]
if (c == '[' || Character.isWhitespace(c) || c == ':' || c == '-') {
return idx;
}
}
// 继续向前查找更早的兼容哨兵
idx = text.lastIndexOf(LOOSE_SENTINEL, idx - 1);
}
return -1;
}
/**
* 同步路径按最后一次出现哨兵的位置分割完整文本
*
* @param rawText LLM 原始输出
* @return 解析结果answer + suggestions
*/
public static ParsedResponse parse(String rawText) {
if (rawText == null || rawText.isEmpty()) {
return new ParsedResponse(rawText != null ? rawText : "", Collections.emptyList());
}
int lastIdx = findSentinelIndex(rawText);
if (lastIdx < 0) {
// 无哨兵标记整段作为 answer
return new ParsedResponse(rawText, Collections.emptyList());
}
String answer = rawText.substring(0, lastIdx).trim();
// 按实际匹配的哨兵长度截取后续内容严格哨兵或兼容哨兵长度不同
int matchedSentinelLength = rawText.startsWith(SENTINEL, lastIdx) ? SENTINEL.length() : LOOSE_SENTINEL.length();
String suggestionsPart = rawText.substring(lastIdx + matchedSentinelLength).trim();
List<String> suggestions = parseSuggestionsJson(suggestionsPart);
return new ParsedResponse(answer, suggestions);
}
/**
* 流式路径从原始 Flux 中分离 answer suggestions
* <p>
* 采用安全缓冲区策略防止哨兵泄漏到 UI
* <ol>
* <li>每次 chunk 追加到累积缓冲区</li>
* <li>安全区域 = 缓冲区去掉末尾 SENTINEL.length() 字符预留哨兵跨 chunk 截断空间</li>
* <li>在安全区域内检查哨兵找到则发出哨兵前的剩余内容后续全进 suggestionsBuffer</li>
* <li>未找到哨兵则发出安全区域内容</li>
* <li>doOnComplete 哨兵已发现则解析 suggestions未发现则发出安全缓冲区内剩余内容</li>
* </ol>
*
* @param rawStream LLM 原始输出流
* @param cache 建议缓存
* @param chatId 会话 ID
* @return answer Flux不含哨兵及之后的 suggestions
*/
/**
* 可变状态容器 lambda 内部修改
*/
private static class StreamState {
final StringBuilder buf = new StringBuilder();
int emitted = 0;
boolean sentinelFound = false;
}
public static Flux<String> parseFromStream(Flux<String> rawStream, SuggestionCache cache, String chatId) {
StreamState state = new StreamState();
return rawStream
.concatMap(chunk -> Flux.<String>create(sink -> {
if (state.sentinelFound) {
sink.complete();
return;
}
state.buf.append(chunk);
int maxSentinelLen = Math.max(SENTINEL.length(), LOOSE_SENTINEL.length());
int safeEnd = Math.max(0, state.buf.length() - maxSentinelLen);
if (safeEnd <= state.emitted) {
sink.complete();
return;
}
String safeRegion = state.buf.substring(state.emitted, safeEnd);
int sentinelIdx = findSentinelIndex(safeRegion);
if (sentinelIdx >= 0) {
state.sentinelFound = true;
String beforeSentinel = safeRegion.substring(0, sentinelIdx);
if (!beforeSentinel.isEmpty()) {
sink.next(beforeSentinel);
}
// 确定实际匹配到的哨兵长度
int matchedLen = safeRegion.startsWith(SENTINEL, sentinelIdx)
? SENTINEL.length()
: LOOSE_SENTINEL.length();
// 哨兵在安全区域内的位置 sentinelIdx相对全缓冲区即 emitted + sentinelIdx
// emitted 推进到哨兵结束后后续内容进 suggestions 解析
state.emitted = state.emitted + sentinelIdx + matchedLen;
log.debug("流式路径检测到哨兵: chatId={}, pos={}, matchedLen={}", chatId, state.emitted, matchedLen);
} else {
if (!safeRegion.isEmpty()) {
sink.next(safeRegion);
}
state.emitted = safeEnd;
}
sink.complete();
}))
// 流结束后发出安全缓冲区内未发出的残留内容无哨兵场景
.concatWith(Flux.defer(() -> {
if (!state.sentinelFound && state.emitted < state.buf.length()) {
String residual = state.buf.substring(state.emitted);
if (!residual.isEmpty()) {
return Flux.just(residual);
}
}
return Flux.empty();
}))
.doOnComplete(() -> {
if (state.sentinelFound) {
String suggestionsPart = state.buf.length() > state.emitted
? state.buf.substring(state.emitted) : "";
List<String> suggestions = parseSuggestionsJson(suggestionsPart.strip());
if (!suggestions.isEmpty()) {
cache.put(chatId, suggestions);
log.info("流式 suggestions 解析成功: chatId={}, count={}", chatId, suggestions.size());
} else {
log.debug("流式 suggestions 解析为空: chatId={}", chatId);
}
}
});
}
/**
* 容错解析 suggestions JSON 数组
* 先尝试标准 JSON 解析失败则按行切分取前 3 条非空行
*/
static List<String> parseSuggestionsJson(String jsonPart) {
if (jsonPart == null || jsonPart.isBlank()) {
return Collections.emptyList();
}
// 1. 尝试标准 JSON 解析
String trimmed = jsonPart.strip();
// 去掉可能的 markdown 代码块包裹
trimmed = trimCodeBlock(trimmed);
try {
List<String> parsed = objectMapper.readValue(trimmed, new TypeReference<List<String>>() {});
List<String> result = new ArrayList<>();
for (String s : parsed) {
if (s != null && !s.isBlank()) {
result.add(s.strip());
}
}
if (!result.isEmpty()) {
return result.size() <= 3 ? result : result.subList(0, 3);
}
} catch (Exception e) {
log.debug("标准 JSON 解析 suggestions 失败,尝试按行切分: {}", e.getMessage());
}
// 2. 降级按行切分取前 3
return parseByLines(trimmed);
}
/**
* 降级解析按行切分去掉序号前缀后取前 3 条非空行
*/
private static List<String> parseByLines(String text) {
List<String> lines = text.lines()
.map(String::strip)
.map(SuggestionResponseParser::stripNumberPrefix)
.map(SuggestionResponseParser::stripQuotes)
.filter(s -> !s.isBlank())
.collect(Collectors.toList());
if (lines.isEmpty()) {
return Collections.emptyList();
}
return lines.size() <= 3 ? lines : lines.subList(0, 3);
}
/** 去掉可能的 markdown 代码块包裹(支持 3 个及以上反引号) */
private static String trimCodeBlock(String s) {
if (s.startsWith("```") && s.endsWith("```")) {
// 计算开头的反引号数量
int openCount = 0;
while (openCount < s.length() && s.charAt(openCount) == '`') openCount++;
if (openCount >= 3 && s.endsWith("`".repeat(openCount))) {
String inner = s.substring(openCount, s.length() - openCount).strip();
if (inner.startsWith("json")) {
inner = inner.substring(4).strip();
} else if (inner.startsWith("JSON")) {
inner = inner.substring(4).strip();
}
return inner;
}
}
return s;
}
/** 去掉行首序号,如 "1." "2." "3." "1、" "2、" "3、" */
private static String stripNumberPrefix(String s) {
if (s.length() >= 2 && Character.isDigit(s.charAt(0)) &&
(s.charAt(1) == '.' || s.charAt(1) == '、' || s.charAt(1) == ')')) {
return s.substring(2).strip();
}
return s;
}
/** 去掉首尾引号 */
private static String stripQuotes(String s) {
if (s.length() >= 2) {
char first = s.charAt(0);
char last = s.charAt(s.length() - 1);
if ((first == '"' && last == '"') || (first == '\'' && last == '\'') ||
(first == '“' && last == '”')) { // 中文引号 " "
return s.substring(1, s.length() - 1).strip();
}
}
return s;
}
/**
* 解析结果值对象
*
* @param answer LLM 回答正文不含哨兵及之后的 suggestions
* @param suggestions 建议问题列表03
*/
public record ParsedResponse(String answer, List<String> suggestions) {
}
}

207
src/main/java/com/wok/supportbot/auth/AuthController.java

@ -3,14 +3,23 @@ package com.wok.supportbot.auth;
import com.wok.supportbot.entity.SysRole;
import com.wok.supportbot.entity.SysUser;
import com.wok.supportbot.security.JwtTokenProvider;
import com.wok.supportbot.security.LoginAttemptService;
import com.wok.supportbot.security.PasswordValidator;
import com.wok.supportbot.service.SysUserService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
@ -19,7 +28,7 @@ import java.util.Map;
/**
* 认证控制器
* 处理登录Token 刷新当前用户信息查询
* 处理登录Token 刷新当前用户信息查询自助改密
*/
@Slf4j
@RestController
@ -29,28 +38,51 @@ public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
private final SysUserService sysUserService;
private final PasswordEncoder passwordEncoder;
private final LoginAttemptService loginAttemptService;
private final PasswordValidator passwordValidator;
private final boolean refreshCookieSecure;
public AuthController(AuthenticationManager authenticationManager,
JwtTokenProvider jwtTokenProvider,
SysUserService sysUserService) {
SysUserService sysUserService,
PasswordEncoder passwordEncoder,
LoginAttemptService loginAttemptService,
PasswordValidator passwordValidator,
@Value("${jwt.refresh-cookie-secure:false}") boolean refreshCookieSecure) {
this.authenticationManager = authenticationManager;
this.jwtTokenProvider = jwtTokenProvider;
this.sysUserService = sysUserService;
this.passwordEncoder = passwordEncoder;
this.loginAttemptService = loginAttemptService;
this.passwordValidator = passwordValidator;
this.refreshCookieSecure = refreshCookieSecure;
}
/**
* 用户登录
* 登录成功后通过 httpOnly Cookie 返回 refresh tokenbody 中只返回 access token
*/
@PostMapping("/login")
public ResponseEntity<Map<String, Object>> login(@RequestBody Map<String, String> body) {
public ResponseEntity<Map<String, Object>> login(@RequestBody Map<String, String> body, HttpServletRequest request) {
String username = body.get("username");
String password = body.get("password");
if (username == null || password == null) {
if (username == null || password == null || username.isBlank() || password.isBlank()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false, "message", "用户名和密码不能为空"
));
}
// 检查账号是否因暴力破解被锁定
int lockedSeconds = loginAttemptService.getLockedSeconds(username);
if (lockedSeconds > 0) {
int minutes = (lockedSeconds + 59) / 60;
return ResponseEntity.status(423).body(Map.of(
"success", false,
"message", "账号已锁定,请 " + minutes + " 分钟后重试"
));
}
try {
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, password)
@ -62,33 +94,37 @@ public class AuthController {
List<SysRole> roles = user.getRoles();
List<String> roleKeys = roles.stream().map(SysRole::getRoleKey).toList();
// 生成 Token
String accessToken = jwtTokenProvider.generateToken(username, roleKeys);
String refreshToken = jwtTokenProvider.generateRefreshToken(username);
int tokenVersion = user.getTokenVersion() == null ? 0 : user.getTokenVersion();
String accessToken = jwtTokenProvider.generateToken(username, roleKeys, tokenVersion);
String refreshToken = jwtTokenProvider.generateRefreshToken(username, tokenVersion);
// 更新最后登录时间
// 更新最后登录时间并清除失败记录
sysUserService.updateLastLoginTime(user.getId());
loginAttemptService.recordSuccess(username);
// 构建用户信息
Map<String, Object> userInfo = new LinkedHashMap<>();
userInfo.put("id", user.getId().toString());
userInfo.put("username", user.getUsername());
userInfo.put("nickname", user.getNickname());
userInfo.put("email", user.getEmail());
userInfo.put("roles", roleKeys);
userInfo.put("avatar", user.getAvatar());
Map<String, Object> userInfo = buildUserInfo(user, roleKeys);
ResponseCookie refreshCookie = buildRefreshCookie(refreshToken, refreshCookieMaxAgeSeconds(), request.isSecure());
log.info("用户登录成功: {}", username);
return ResponseEntity.ok(Map.of(
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
.body(Map.of(
"success", true,
"data", Map.of(
"accessToken", accessToken,
"refreshToken", refreshToken,
"user", userInfo
)
));
} catch (BadCredentialsException e) {
return ResponseEntity.ok(Map.of(
// 密码错误等认证失败场景记录失败次数
loginAttemptService.recordFailure(username);
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "用户名或密码错误"
));
} catch (AuthenticationException e) {
// 用户不存在账号禁用等也统一返回 401避免泄露账号状态
loginAttemptService.recordFailure(username);
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "用户名或密码错误"
));
}
@ -96,34 +132,115 @@ public class AuthController {
/**
* 刷新 Token
* httpOnly Cookie 读取 refresh token换发新的 access token refresh cookie
*/
@PostMapping("/refresh")
public ResponseEntity<Map<String, Object>> refresh(@RequestBody Map<String, String> body) {
String refreshToken = body.get("refreshToken");
if (refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) {
return ResponseEntity.ok(Map.of(
public ResponseEntity<Map<String, Object>> refresh(
@CookieValue(name = "refresh_token", required = false) String refreshToken,
HttpServletRequest request) {
if (refreshToken == null || !jwtTokenProvider.validateRefreshToken(refreshToken)) {
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "刷新令牌无效或已过期"
));
}
String username = jwtTokenProvider.getUsernameFromToken(refreshToken);
int tokenVer = jwtTokenProvider.getTokenVersionFromToken(refreshToken);
SysUser user = sysUserService.getUserByUsername(username);
if (user == null || Boolean.FALSE.equals(user.getEnabled())) {
return ResponseEntity.ok(Map.of(
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "用户不存在或已被禁用"
));
}
int currentVer = user.getTokenVersion() == null ? 0 : user.getTokenVersion();
if (currentVer != tokenVer) {
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "登录状态已失效,请重新登录"
));
}
List<String> roleKeys = user.getRoles().stream().map(SysRole::getRoleKey).toList();
String newAccessToken = jwtTokenProvider.generateToken(username, roleKeys);
String newRefreshToken = jwtTokenProvider.generateRefreshToken(username);
String newAccessToken = jwtTokenProvider.generateToken(username, roleKeys, currentVer);
String newRefreshToken = jwtTokenProvider.generateRefreshToken(username, currentVer);
return ResponseEntity.ok(Map.of(
ResponseCookie refreshCookie = buildRefreshCookie(newRefreshToken, refreshCookieMaxAgeSeconds(), request.isSecure());
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
.body(Map.of(
"success", true,
"data", Map.of(
"accessToken", newAccessToken,
"refreshToken", newRefreshToken
)
"data", Map.of("accessToken", newAccessToken)
));
}
/**
* 退出登录清除 refresh token cookie并使当前 access token 立即失效
*/
@PostMapping("/logout")
public ResponseEntity<Map<String, Object>> logout(HttpServletRequest request) {
// 递增 token_version使已签发的 access token 立即失效剩余有效期内的令牌不再可用
// 注意改密/禁用后 token 已失效SecurityContext 可能为空此时仅清除 cookie 即可
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
SysUser user = sysUserService.getUserByUsername(auth.getName());
if (user != null) {
sysUserService.incrementTokenVersion(user.getId());
}
}
ResponseCookie clearCookie = ResponseCookie.from("refresh_token", "")
.httpOnly(true)
.secure(refreshCookieSecure && request.isSecure())
.sameSite("Strict")
.path("/auth/refresh")
.maxAge(0)
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, clearCookie.toString())
.body(Map.of("success", true, "message", "已退出登录"));
}
/**
* 当前登录用户自助修改密码必须校验旧密码
*/
@PutMapping("/password")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Map<String, Object>> changePassword(@RequestBody Map<String, String> body) {
String oldPassword = body.get("oldPassword");
String newPassword = body.get("newPassword");
if (oldPassword == null || oldPassword.isBlank() || newPassword == null || newPassword.isBlank()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false, "message", "旧密码和新密码不能为空"
));
}
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
SysUser user = sysUserService.getUserByUsername(username);
if (user == null) {
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "用户不存在"
));
}
if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "旧密码错误"
));
}
try {
passwordValidator.validate(newPassword);
sysUserService.changePassword(user.getId(), newPassword);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of(
"success", false, "message", e.getMessage()
));
}
return ResponseEntity.ok(Map.of(
"success", true, "message", "密码已修改,请重新登录"
));
}
@ -148,7 +265,10 @@ public class AuthController {
}
List<String> roleKeys = user.getRoles().stream().map(SysRole::getRoleKey).toList();
return ResponseEntity.ok(Map.of("success", true, "data", buildUserInfo(user, roleKeys)));
}
private Map<String, Object> buildUserInfo(SysUser user, List<String> roleKeys) {
Map<String, Object> userInfo = new LinkedHashMap<>();
userInfo.put("id", user.getId().toString());
userInfo.put("username", user.getUsername());
@ -156,7 +276,30 @@ public class AuthController {
userInfo.put("email", user.getEmail());
userInfo.put("roles", roleKeys);
userInfo.put("avatar", user.getAvatar());
return userInfo;
}
return ResponseEntity.ok(Map.of("success", true, "data", userInfo));
/**
* refresh cookie Max-Age
* 直接取自 jwt.refresh-expiration 配置保证 Cookie 生命周期与 refresh token 完全一致
* 避免两处硬编码后改配置不改 Cookie导致的有效期漂移
*/
private long refreshCookieMaxAgeSeconds() {
return jwtTokenProvider.getRefreshExpirationSeconds();
}
/**
* 构建 refresh token Cookie
* Secure 属性按配置开关 && 当前请求是否 HTTPS动态决定
* 浏览器仅在 HTTPS 下接受 Secure cookie本地 HTTP 开发环境必须降级为 false否则刷新会失效
*/
private ResponseCookie buildRefreshCookie(String refreshToken, long maxAgeSeconds, boolean isSecureRequest) {
return ResponseCookie.from("refresh_token", refreshToken)
.httpOnly(true)
.secure(refreshCookieSecure && isSecureRequest)
.sameSite("Strict")
.path("/auth/refresh")
.maxAge(maxAgeSeconds)
.build();
}
}

85
src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java

@ -1,85 +0,0 @@
package com.wok.supportbot.chatmemory;
import com.esotericsoftware.kryo.Kryo;
import com.esotericsoftware.kryo.io.Input;
import com.esotericsoftware.kryo.io.Output;
import org.objenesis.strategy.StdInstantiatorStrategy;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* 基于文件持久化的对话记忆
*/
public class FileBasedChatMemory implements ChatMemory {
private final String BASE_DIR;
private static final Kryo kryo = new Kryo();
static {
kryo.setRegistrationRequired(false);
// 设置实例化策略
kryo.setInstantiatorStrategy(new StdInstantiatorStrategy());
}
// 构造对象时指定文件保存目录
public FileBasedChatMemory(String dir) {
this.BASE_DIR = dir;
File baseDir = new File(dir);
if (!baseDir.exists()) {
baseDir.mkdirs();
}
}
@Override
public void add(String conversationId, List<Message> messages) {
List<Message> conversationMessages = getOrCreateConversation(conversationId);
conversationMessages.addAll(messages);
saveConversation(conversationId, conversationMessages);
}
@Override
public List<Message> get(String conversationId) {
return getOrCreateConversation(conversationId);
}
@Override
public void clear(String conversationId) {
File file = getConversationFile(conversationId);
if (file.exists()) {
file.delete();
}
}
private List<Message> getOrCreateConversation(String conversationId) {
File file = getConversationFile(conversationId);
List<Message> messages = new ArrayList<>();
if (file.exists()) {
try (Input input = new Input(new FileInputStream(file))) {
messages = kryo.readObject(input, ArrayList.class);
} catch (IOException e) {
e.printStackTrace();
}
}
return messages;
}
private void saveConversation(String conversationId, List<Message> messages) {
File file = getConversationFile(conversationId);
try (Output output = new Output(new FileOutputStream(file))) {
kryo.writeObject(output, messages);
} catch (IOException e) {
e.printStackTrace();
}
}
private File getConversationFile(String conversationId) {
return new File(BASE_DIR, conversationId + ".kryo");
}
}

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 视为升序其余 nulldesc其他值一律降序
*/
public static boolean isAscending(String sortOrder) {
return "asc".equalsIgnoreCase(sortOrder);
}
}

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

@ -33,4 +33,40 @@ public class AsyncExecutorConfig {
executor.initialize();
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;
}
/**
* RAG MULTI_QUERY 多路向量检索扇出线程池性能优化
* 各路检索相互独立embedding HTTP + PG 向量查询并行执行可将N 路串行降为1 段最慢
* 显著降低首 token 前的检索耗时有界队列 + CallerRunsPolicy饱和时退回请求线程串行天然降级
* traceExecutor/documentExecutor 分离避免与日志/文档向量化任务互相挤占
*/
@Bean("ragRetrieveExecutor")
public ThreadPoolTaskExecutor ragRetrieveExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(32);
executor.setThreadNamePrefix("rag-retrieve-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}

8
src/main/java/com/wok/supportbot/config/ChatModelFactory.java

@ -66,8 +66,8 @@ public class ChatModelFactory {
/** AI 模型调用连接超时 */
private static final Duration AI_CONNECT_TIMEOUT = Duration.ofSeconds(10);
/** AI 模型调用读取超时,含流式生成时间 */
private static final Duration AI_READ_TIMEOUT = Duration.ofSeconds(120);
/** AI 模型调用读取超时,含流式生成时间(推理模型思考+生成耗时较长,放宽到 300s) */
private static final Duration AI_READ_TIMEOUT = Duration.ofSeconds(300);
/**
* 构建带超时配置的 RestClient.Builder DashScope OpenAI 兼容路径共用
@ -157,7 +157,7 @@ public class ChatModelFactory {
}
DashScopeApi api = apiBuilder.build();
DashScopeChatOptions.DashscopeChatOptionsBuilder optionsBuilder = DashScopeChatOptions.builder()
DashScopeChatOptions.DashScopeChatOptionsBuilder optionsBuilder = DashScopeChatOptions.builder()
.withModel(config.getModelName());
if (config.getTemperature() != null) {
optionsBuilder.withTemperature(config.getTemperature());
@ -352,7 +352,7 @@ public class ChatModelFactory {
/**
* extraConfig 中读取高级参数并设置到 DashScopeChatOptions.Builder
*/
private void applyDashScopeExtraConfig(AiModelConfig config, DashScopeChatOptions.DashscopeChatOptionsBuilder optionsBuilder) {
private void applyDashScopeExtraConfig(AiModelConfig config, DashScopeChatOptions.DashScopeChatOptionsBuilder optionsBuilder) {
Map<String, Object> extra = config.getExtraConfig();
if (extra == null || extra.isEmpty()) return;

31
src/main/java/com/wok/supportbot/config/CorsConfig.java

@ -1,25 +1,48 @@
package com.wok.supportbot.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays;
import java.util.List;
/**
* 全局跨域配置
* SecurityConfig 中的 CORS 配置保持一致的来源白名单
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
private final List<String> allowedOrigins;
public CorsConfig(
@Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:5174,http://localhost:9090}") String allowedOrigins) {
this.allowedOrigins = Arrays.stream(allowedOrigins.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// 覆盖所有请求
registry.addMapping("/**")
// 允许发送 Cookie
// SDK 第三方接入接口开放跨域第三方域名动态未知使用 Bearer Token 鉴权 refresh cookie 暴露风险
for (String path : new String[]{"/ai/**", "/category/tree", "/category/list", "/feedback", "/attachment/upload"}) {
registry.addMapping(path)
.allowCredentials(true)
// 放行哪些域名必须用 patterns否则 * 会和 allowCredentials 冲突
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("*");
}
// 管理后台接口来源白名单保护 httpOnly refresh cookie防跨域 CSRF
registry.addMapping("/**")
.allowCredentials(true)
.allowedOriginPatterns(allowedOrigins.toArray(new String[0]))
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("*");
}
}

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

@ -62,11 +62,19 @@ public class DatabaseInitConfig {
safeInit("迁移 knowledge_document.extra_config 列", this::addDocumentExtraConfigColumn);
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", () -> {
if (!checkTableExists("customer_service_role")) {
createCustomerServiceRoleTable();
} else {
dropRoleModelColumn();
dropRoleSortOrderColumn();
}
});
@ -155,6 +163,7 @@ public class DatabaseInitConfig {
createSysUserTable();
}
});
safeInit("迁移 sys_user.token_version 列", this::addTokenVersionColumn);
safeInit("创建系统角色表 sys_role", () -> {
if (!checkTableExists("sys_role")) {
createSysRoleTable();
@ -234,6 +243,13 @@ public class DatabaseInitConfig {
});
safeInit("同步默认系统配置", this::syncDefaultSystemConfigs);
// ==================== 登录安全 ====================
safeInit("创建登录失败记录表 login_attempt", () -> {
if (!checkTableExists("login_attempt")) {
createLoginAttemptTable();
}
});
// 为所有表添加注释幂等可重复执行
safeInit("应用数据库表注释", this::applyTableComments);
@ -260,6 +276,7 @@ public class DatabaseInitConfig {
private void verifyInitialization() {
String[] expectedTables = {
"chat_message", "knowledge_category", "knowledge_document",
"knowledge_folder",
"customer_service_role", "customer_service_role_category",
"customer_account", "conversation_session", "ai_model_config",
"sensitive_word", "content_audit_log", "message_feedback",
@ -269,7 +286,8 @@ public class DatabaseInitConfig {
"api_key", "webhook_config",
"mcp_server_config",
"system_config",
"llm_call_trace"
"llm_call_trace",
"login_attempt"
};
java.util.List<String> missingTables = new java.util.ArrayList<>();
@ -337,6 +355,26 @@ public class DatabaseInitConfig {
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() {
String sql = """
CREATE TABLE IF NOT EXISTS knowledge_document (
@ -595,6 +633,18 @@ public class DatabaseInitConfig {
}
}
/**
* 清理误加的 customer_service_role.sort_order 角色表无排序权重需求 id 排序
* 该列无任何代码引用系误加/遗留的孤儿列幂等DROP COLUMN IF EXISTS列不存在时不做任何事
*/
private void dropRoleSortOrderColumn() {
try {
jdbcTemplate.execute("ALTER TABLE customer_service_role DROP COLUMN IF EXISTS sort_order");
} catch (Exception e) {
log.warn("清理 sort_order 列时出错", e);
}
}
/**
* 自动添加 content_hash 二期去重功能新增字段
*/
@ -663,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: 内容安全过滤 ====================
private void createSensitiveWordTable() {
@ -861,7 +930,9 @@ public class DatabaseInitConfig {
/**
* vector_store 表添加全文检索支持tsvector + GIN 索引 + 触发器
* 幂等操作已存在则跳过
* 全幂等自愈每一步都用幂等 SQLADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS /
* CREATE OR REPLACE + DROP TRIGGER IF EXISTS不做列已存在就 return的早退
* 避免半途失败或触发器被手动删除后新文档 content_tsvector 恒为 NULL 的静默缺口
*/
private void initVectorStoreFullTextSearch() {
try {
@ -871,19 +942,12 @@ public class DatabaseInitConfig {
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");
// GIN 索引
@ -953,6 +1017,7 @@ public class DatabaseInitConfig {
phone VARCHAR(20),
avatar VARCHAR(512),
enabled BOOLEAN DEFAULT TRUE NOT NULL,
token_version INT DEFAULT 0 NOT NULL,
last_login_time TIMESTAMP,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
@ -1009,6 +1074,45 @@ public class DatabaseInitConfig {
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_sys_user_role_role ON sys_user_role (role_id)");
}
/**
* 迁移 sys_user.token_version JWT 服务端撤销机制幂等
*/
private void addTokenVersionColumn() {
try {
String checkSql = """
SELECT COUNT(*) FROM information_schema.columns
WHERE table_name = 'sys_user' AND column_name = 'token_version'
""";
Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class);
if (count != null && count == 0) {
jdbcTemplate.execute("ALTER TABLE sys_user ADD COLUMN token_version INT DEFAULT 0 NOT NULL");
log.info("已迁移 sys_user.token_version 列");
}
} catch (Exception e) {
log.error("迁移 sys_user.token_version 列失败: {}", e.getMessage());
throw e;
}
}
/**
* 创建登录失败记录表暴力破解防护
*/
private void createLoginAttemptTable() {
String sql = """
CREATE TABLE IF NOT EXISTS login_attempt (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
failed_count INT DEFAULT 0 NOT NULL,
last_failed_time TIMESTAMP,
locked_until TIMESTAMP,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""";
jdbcTemplate.execute(sql);
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_login_attempt_locked ON login_attempt (locked_until)");
}
/**
* chat_message 表增加 user_id 数据隔离迁移幂等
*/
@ -1335,18 +1439,10 @@ public class DatabaseInitConfig {
""", "ai_system_prompt", "", "AI 对话全局系统提示词为空则不注入修改后即时生效无需重启");
// AI 推荐问题suggest-message-list种子数据
// 格式要求由 SuggestionGenerator.buildPrompt 统一追加此处仅提供基础文案
// 不再要求模型输出 ___SUGGESTIONS___ 哨兵标记避免与新架构"只输出 JSON 数组"要求矛盾
String defaultSuggestionPrompt = """
推荐问题生成规则
在回答正文结束后请严格按以下格式生成 3 条用户可能继续追问的推荐问题
___SUGGESTIONS___
["推荐问题1", "推荐问题2", "推荐问题3"]
要求
1. 推荐问题需与当前回答内容相关覆盖用户可能关心的不同方面
2. 推荐问题是用户可直接点击发送的完整问题文本
3. 必须是合法的 JSON 字符串数组
4. ___SUGGESTIONS___ 是分隔标记不要在回答正文中出现此标记
请根据历史对话生成 3 条用户最可能继续提问的推荐问题
""";
jdbcTemplate.update("""
@ -1404,8 +1500,9 @@ public class DatabaseInitConfig {
executeComment("COLUMN knowledge_document.source_name", "原始文件名");
executeComment("COLUMN knowledge_document.file_type", "文件类型: pdf / md / json / txt / word / excel 等");
executeComment("COLUMN knowledge_document.file_size", "文件大小(字节)");
executeComment("COLUMN knowledge_document.content", "原文内容(截断预览)");
executeComment("COLUMN knowledge_document.content", "原文全文(用于重新处理时重建分块;接口仅返回 2000 字预览)");
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.chunk_count", "分块数量");
executeComment("COLUMN knowledge_document.status", "处理状态: PROCESSING / READY / FAILED");
@ -1418,6 +1515,17 @@ public class DatabaseInitConfig {
executeComment("COLUMN knowledge_document.update_time", "更新时间");
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 =====
executeComment("TABLE customer_service_role", "客服角色表(定义客服角色的身份与系统提示词)");
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.api.OpenAiApi;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.support.RetryTemplate;
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.List;
import java.util.Map;
@ -110,7 +114,8 @@ public class EmbeddingModelFactory {
if ("dashscope".equalsIgnoreCase(config.getProvider())) {
log.info("创建 DashScope EmbeddingModel: model={}, baseUrl={}", config.getModelName(), baseUrl);
DashScopeApi.Builder apiBuilder = DashScopeApi.builder()
.apiKey(config.getApiKey());
.apiKey(config.getApiKey())
.restClientBuilder(timeoutRestClientBuilder());
// 团队版/私有化部署使用自定义 baseUrl
if (baseUrl != null && !baseUrl.isBlank()) {
apiBuilder.baseUrl(baseUrl);
@ -147,6 +152,7 @@ public class EmbeddingModelFactory {
.apiKey(config.getApiKey())
.baseUrl(baseUrl)
.embeddingsPath(embeddingsPath)
.restClientBuilder(timeoutRestClientBuilder())
.build();
OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder()
@ -223,6 +229,28 @@ public class EmbeddingModelFactory {
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 超时的 RestClientDashScope / 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: 连接测试 ====================
/**

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.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.web.client.RestClient;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@ -39,6 +42,12 @@ public class VolcengineMultimodalEmbeddingModel implements EmbeddingModel {
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 baseUrl;
private final String modelName;
@ -54,8 +63,15 @@ public class VolcengineMultimodalEmbeddingModel implements EmbeddingModel {
this.modelName = modelName;
this.dimensions = dimensions;
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()
.baseUrl(baseUrl)
.requestFactory(requestFactory)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();

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

@ -58,9 +58,11 @@ public class AiModelConfigController {
public ResponseEntity<Map<String, Object>> listConfigs(
@RequestParam(defaultValue = "1") int page,
@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 {
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<>();
data.put("success", true);
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')")
public ResponseEntity<Map<String, Object>> list(
@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 {
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<>();
data.put("success", true);

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

@ -3,7 +3,6 @@ package com.wok.supportbot.controller;
import com.wok.supportbot.config.StorageProperties;
import com.wok.supportbot.service.SftpStorageService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@ -35,11 +34,14 @@ public class AttachmentController {
/**
* 上传图片/附件
*
* <p>鉴权由 {@code SdkAuthFilter} 承担拦截 /attachment/upload验证 SDK JWT 或管理后台 JWT
* /ai/** 接口一致方法层不再叠加 @PreAuthorize否则 SDK Token 会被 JwtAuthFilter 用管理后台
* 密钥重复验证失败导致 isAuthenticated() 判定为未认证
*
* @param file 上传的文件
* @return 上传结果data 内含 url / name / type / mimeType / size
*/
@PostMapping("/upload")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Map<String, Object>> upload(@RequestParam("file") MultipartFile file) {
try {
validateFile(file);

12
src/main/java/com/wok/supportbot/controller/AuthController.java

@ -88,12 +88,12 @@ public class AuthController {
.map(RoleBrief::id)
.collect(Collectors.toList());
// 4. 计算过期时间默认 2 小时钳制到 [5min, 24h]
long ttlMs = 7200000L;
if (body != null && body.ttl() != null) {
ttlMs = Math.max(body.ttl() * 1000L, 300000L);
ttlMs = Math.min(ttlMs, 86400000L);
}
// 4. 计算过期时间调用方指定 ttl则用指定值否则取 jwt.sdk-expiration 配置的默认值
// 再统一按 [5min, 24h] 钳制保证返回的 expiresIn token 实际有效期一致
long ttlMs = (body != null && body.ttl() != null)
? body.ttl() * 1000L
: sdkJwtTokenProvider.getDefaultExpirationMillis();
ttlMs = sdkJwtTokenProvider.clampExpirationMillis(ttlMs);
// 5. 签发 SDK JWTsubject = apiKeyId
String token = sdkJwtTokenProvider.generateToken(

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

@ -48,9 +48,11 @@ public class ConversationController {
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword,
@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 {
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<>();
data.put("success", true);
data.put("data", result.get("records"));

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

@ -1,5 +1,7 @@
package com.wok.supportbot.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.entity.CategoryNode;
import com.wok.supportbot.entity.KnowledgeCategory;
import com.wok.supportbot.entity.KnowledgeDocument;
@ -30,6 +32,11 @@ public class DocumentController {
private DocumentService documentService;
@Autowired
private CategoryFilter categoryFilter;
@Autowired
private ObjectMapper objectMapper;
/** 文档详情接口返回的原文预览上限(字符)。仅影响传输展示,不影响存库与重新处理 */
private static final int CONTENT_PREVIEW_LIMIT = 2000;
// ==================== 上传校验常量 ====================
@ -74,6 +81,7 @@ public class DocumentController {
* @param file 文件
* @param title 文档标题可选默认使用文件名
* @param categoryId 分类ID可选
* @param folderId 目录ID可选
* @param tags 标签可选
* @return 上传结果
*/
@ -83,12 +91,13 @@ public class DocumentController {
@RequestParam("file") MultipartFile file,
@RequestParam(required = false) String title,
@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 {
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(
"success", true,
"message", "文件上传成功,正在后台处理",
@ -117,11 +126,12 @@ public class DocumentController {
@RequestBody String content,
@RequestParam String title,
@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 {
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(
"success", true,
"message", "文本内容上传成功,正在后台处理",
@ -144,12 +154,13 @@ public class DocumentController {
@RequestParam("file") MultipartFile file,
@RequestParam(required = false) String title,
@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 {
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(
"success", true,
"message", "Markdown文件上传成功,正在后台处理",
@ -172,12 +183,13 @@ public class DocumentController {
@RequestParam("file") MultipartFile file,
@RequestParam(required = false) String title,
@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 {
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(
"success", true,
"message", "JSON文件上传成功,正在后台处理",
@ -201,12 +213,13 @@ public class DocumentController {
@RequestParam("fields") List<String> fields,
@RequestParam(required = false) String title,
@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 {
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(
"success", true,
"message", "JSON文件(按字段)上传成功,正在后台处理",
@ -231,12 +244,13 @@ public class DocumentController {
@RequestParam("pointer") String pointer,
@RequestParam(required = false) String title,
@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 {
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(
"success", true,
"message", "JSON文件(按指针)上传成功,正在后台处理",
@ -251,6 +265,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()
));
}
}
// ==================== 文档管理 ====================
/**
@ -290,14 +336,14 @@ public class DocumentController {
}
/**
* 查询文档列表分页 + 过滤 + 关键词搜索 + 标签筛选
* 查询文档列表分页 + 过滤 + 关键词搜索
*
* @param page 页码默认1
* @param size 每页大小默认10
* @param categoryId 分类ID过滤可选
* @param folderId 目录ID过滤可选0 表示只看根目录
* @param status 状态过滤PROCESSING/READY/FAILED可选
* @param keyword 关键词搜索模糊匹配标题和文件名可选
* @param tag 标签筛选精确匹配可选
* @return 分页文档列表
*/
@GetMapping("/document/list")
@ -306,11 +352,13 @@ public class DocumentController {
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) Long folderId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String tag) {
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
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, sortField, sortOrder);
Map<String, Object> data = new HashMap<>();
data.put("success", true);
data.put("data", result.get("records"));
@ -343,7 +391,7 @@ public class DocumentController {
}
return ResponseEntity.ok(Map.of(
"success", true,
"data", doc
"data", toDetailMap(doc)
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
@ -353,6 +401,28 @@ public class DocumentController {
}
}
/**
* 组装文档详情响应
* <p>
* 实体上的 {@code content} {@code @JsonIgnore}存的是全文可达数十万字符不能直接返回给前端
* 此处显式补回键名仍为 {@code content} **截断预览**并附 {@code contentTruncated} 标志供前端提示
* 预览仅用于展示与存库内容与重新处理的数据源无关
*/
private Map<String, Object> toDetailMap(KnowledgeDocument doc) {
Map<String, Object> data = objectMapper.convertValue(doc, new TypeReference<Map<String, Object>>() {});
String content = doc.getContent();
boolean contentComplete = doc.getExtraConfig() != null
&& Boolean.TRUE.equals(doc.getExtraConfig().get("contentComplete"));
// 历史遗留文档没有 contentComplete 标记 content 2000 字截断预览
// 长度恰好等于上限时也必须提示已截断否则会把截断预览误报成完整内容
boolean truncated = content != null
&& (content.length() > CONTENT_PREVIEW_LIMIT
|| (content.length() == CONTENT_PREVIEW_LIMIT && !contentComplete));
data.put("content", truncated ? content.substring(0, CONTENT_PREVIEW_LIMIT) : content);
data.put("contentTruncated", truncated);
return data;
}
/**
* 获取文档的所有分块
*/
@ -580,27 +650,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")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> batchMoveDocuments(@RequestBody Map<String, Object> body) {
try {
List<Long> ids = extractIds(body);
Long categoryId = extractCategoryId(body);
Long categoryId = extractLong(body, "categoryId");
Long folderId = extractLong(body, "folderId");
if (ids.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "请提供文档ID列表"
));
}
int updatedCount = documentService.batchMoveDocuments(ids, categoryId);
int updatedCount = documentService.moveDocuments(ids, categoryId, folderId);
int requestedCount = ids.size();
String message;
if (updatedCount == requestedCount) {
message = String.format("已将 %d 个文档移动到目标分类", updatedCount);
message = String.format("已将 %d 个文档移动到目标位置", updatedCount);
} else {
message = String.format("移动完成:成功 %d 个,%d 个文档未找到",
updatedCount, requestedCount - updatedCount);
@ -613,27 +684,28 @@ public class DocumentController {
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "批量移动失败:" + e.getMessage()
"message", "移动失败:" + e.getMessage()
));
}
}
/**
* 从请求体中提取 categoryId兼容 Number String 两种类型
* 从请求体中提取指定 key Long 兼容 Number 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) {
return 0L;
return null;
}
if (raw instanceof Number) {
return ((Number) raw).longValue();
}
if (raw instanceof String str) {
return str.isEmpty() ? 0L : Long.parseLong(str);
return str.isEmpty() ? null : Long.parseLong(str);
}
return 0L;
return null;
}
// ==================== 语义搜索 ====================
@ -682,29 +754,6 @@ public class DocumentController {
return categoryFilter.parse(rawCategoryIds);
}
// ==================== 标签管理 ====================
/**
* P1-2.3: 获取标签列表从所有文档的 tags 聚合去重含使用次数
*/
@GetMapping("/tag/list")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> getTagList() {
try {
List<Map<String, Object>> tags = documentService.getTagList();
return ResponseEntity.ok(Map.of(
"success", true,
"data", tags,
"total", tags.size()
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "获取标签列表失败:" + e.getMessage()
));
}
}
// ==================== 分块管理 ====================
/**

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

@ -36,9 +36,11 @@ public class FaqController {
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword,
@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 {
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(
"success", true,
"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 endTime,
@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 {
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<>();
data.put("success", true);
data.put("data", result.get("records"));

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

@ -1,5 +1,6 @@
package com.wok.supportbot.controller;
import com.wok.supportbot.app.AssistantApp;
import com.wok.supportbot.config.McpClientManager;
import com.wok.supportbot.entity.McpServerConfig;
import com.wok.supportbot.mcp.McpToolCallbackAdapter;
@ -29,6 +30,9 @@ public class McpServerConfigController {
@Autowired
private McpClientManager mcpClientManager;
@Autowired
private AssistantApp assistantApp;
// ==================== 分页列表 ====================
/**
@ -42,9 +46,11 @@ public class McpServerConfigController {
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> listConfigs(
@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 {
Map<String, Object> result = mcpServerConfigService.listConfigs(page, size);
Map<String, Object> result = mcpServerConfigService.listConfigs(page, size, sortField, sortOrder);
Map<String, Object> data = new LinkedHashMap<>();
data.put("success", true);
data.put("data", result.get("records"));
@ -159,6 +165,8 @@ public class McpServerConfigController {
} catch (Exception e) {
log.warn("新建配置后创建客户端失败(不影响配置保存): {}", e.getMessage());
}
// MCP 工具清单可能变化清空 AssistantApp ChatClient 缓存避免继续使用旧工具集
assistantApp.clearCache();
}
return ResponseEntity.ok(Map.of(
"success", true,
@ -196,6 +204,8 @@ public class McpServerConfigController {
Map<String, Object> updated = mcpServerConfigService.updateConfig(id, config);
// 增量操作仅重建更新的配置客户端不影响其他已有连接
mcpClientManager.rebuildClient(id);
// MCP 工具清单可能变化清空 AssistantApp ChatClient 缓存避免继续使用旧工具集
assistantApp.clearCache();
return ResponseEntity.ok(Map.of(
"success", true,
"data", updated,
@ -229,6 +239,8 @@ public class McpServerConfigController {
mcpServerConfigService.deleteConfig(id);
// 增量操作仅移除被删除的配置客户端不影响其他已有连接
mcpClientManager.removeClient(id);
// MCP 工具清单可能变化清空 AssistantApp ChatClient 缓存避免继续使用旧工具集
assistantApp.clearCache();
return ResponseEntity.ok(Map.of(
"success", true,
"message", "配置删除成功"
@ -252,16 +264,21 @@ public class McpServerConfigController {
* 切换配置启用/禁用状态
*
* @param id 配置ID
* @param body 请求体{active: true/false}
* @param body 请求体可选{active: true/false}
* @param active query 兜底参数可选兼容不带 body 的旧调用方active query string
* @return 操作结果
*/
@PutMapping("/mcp-server/{id}/toggle")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> toggleActive(
@PathVariable("id") Long id,
@RequestBody Map<String, Boolean> body) {
@RequestBody(required = false) Map<String, Boolean> body,
@RequestParam(value = "active", required = false) Boolean activeParam) {
try {
Boolean active = body.get("active");
Boolean active = (body != null) ? body.get("active") : null;
if (active == null) {
active = activeParam;
}
if (active == null) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
@ -275,6 +292,8 @@ public class McpServerConfigController {
} else {
mcpClientManager.disableClient(id);
}
// MCP 工具清单可能变化清空 AssistantApp ChatClient 缓存避免继续使用旧工具集
assistantApp.clearCache();
return ResponseEntity.ok(Map.of(
"success", true,
"message", active ? "配置已启用" : "配置已禁用"
@ -328,6 +347,8 @@ public class McpServerConfigController {
public ResponseEntity<Map<String, Object>> refreshAll() {
try {
mcpClientManager.refreshAll();
// MCP 工具清单可能变化清空 AssistantApp ChatClient 缓存避免继续使用旧工具集
assistantApp.clearCache();
return ResponseEntity.ok(Map.of(
"success", true,
"message", "MCP 客户端连接已刷新"

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) Boolean processed,
@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 {
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(
"success", true,
"data", data

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

@ -1,5 +1,6 @@
package com.wok.supportbot.controller;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.entity.SensitiveWord;
import com.wok.supportbot.service.SensitiveWordService;
import lombok.extern.slf4j.Slf4j;
@ -27,6 +28,12 @@ public class SensitiveWordController {
@Autowired
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 ====================
/**
@ -39,9 +46,11 @@ public class SensitiveWordController {
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword,
@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 {
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<>();
data.put("success", true);
@ -202,7 +211,9 @@ public class SensitiveWordController {
public ResponseEntity<Map<String, Object>> auditLog(
@RequestParam(defaultValue = "1") int page,
@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 {
// 构建 WHERE 子句
StringBuilder whereClause = new StringBuilder("WHERE 1=1");
@ -217,8 +228,11 @@ public class SensitiveWordController {
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 +
" ORDER BY create_time DESC LIMIT ? OFFSET ?";
" ORDER BY " + orderBy + " LIMIT ? OFFSET ?";
List<Object> queryParams = new java.util.ArrayList<>(params);
queryParams.add(size);
queryParams.add((long) (page - 1) * size);

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

@ -32,9 +32,11 @@ public class SysUserController {
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@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 {
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));
} catch (Exception e) {
log.error("查询用户列表失败", e);
@ -76,10 +78,7 @@ public class SysUserController {
String nickname = (String) body.get("nickname");
String email = (String) body.get("email");
String phone = (String) body.get("phone");
@SuppressWarnings("unchecked")
List<Long> roleIds = body.get("roleIds") != null
? ((List<Number>) body.get("roleIds")).stream().map(Number::longValue).toList()
: List.of();
List<Long> roleIds = parseRoleIds(body.get("roleIds"));
var user = sysUserService.createUser(username, password, nickname, email, phone, roleIds);
return ResponseEntity.ok(Map.of("success", true, "message", "创建成功", "data", Map.of("id", user.getId().toString())));
@ -141,10 +140,7 @@ public class SysUserController {
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> assignRoles(@PathVariable Long id, @RequestBody Map<String, Object> body) {
try {
@SuppressWarnings("unchecked")
List<Long> roleIds = body.get("roleIds") != null
? ((List<Number>) body.get("roleIds")).stream().map(Number::longValue).toList()
: List.of();
List<Long> roleIds = parseRoleIds(body.get("roleIds"));
sysUserService.assignRoles(id, roleIds);
return ResponseEntity.ok(Map.of("success", true, "message", "角色分配成功"));
} catch (Exception e) {
@ -194,4 +190,26 @@ public class SysUserController {
));
}
}
/**
* 解析请求体中的 roleIds 字段为 Long 列表
* 兼容前端传数字 ID 或字符串 ID 两种情况雪花 ID 超过 JS 精度时前端通常传字符串
*/
private List<Long> parseRoleIds(Object roleIdsObj) {
if (roleIdsObj == null) {
return List.of();
}
if (!(roleIdsObj instanceof List<?> list)) {
return List.of();
}
return list.stream()
.filter(java.util.Objects::nonNull)
.map(item -> {
if (item instanceof Number number) {
return number.longValue();
}
return Long.parseLong(String.valueOf(item));
})
.toList();
}
}

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

@ -36,9 +36,11 @@ public class WebhookController {
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> list(
@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 {
Map<String, Object> result = webhookService.listWebhooks(page, size);
Map<String, Object> result = webhookService.listWebhooks(page, size, sortField, sortOrder);
Map<String, Object> data = new HashMap<>();
data.put("success", true);

83
src/main/java/com/wok/supportbot/converter/MessageConverter.java

@ -2,6 +2,9 @@ package com.wok.supportbot.converter;
import com.wok.supportbot.entity.ChatMessage;
import org.springframework.ai.chat.messages.*;
import org.springframework.ai.content.Media;
import org.springframework.util.MimeType;
import org.springframework.util.MimeTypeUtils;
import java.util.*;
@ -16,6 +19,8 @@ public class MessageConverter {
private static final String META_KEY_TOOL_CALLS = "_toolCalls";
/** metadata 中存储 toolResponses 的内部键名 */
private static final String META_KEY_TOOL_RESPONSES = "_toolResponses";
/** metadata 中存储多模态图片 Media 的内部键名 */
private static final String META_KEY_MEDIA = "_media";
/**
* Spring AI Message 转换为数据库实体 ChatMessage
@ -54,6 +59,26 @@ public class MessageConverter {
metadata.put(META_KEY_TOOL_RESPONSES, responsesJson);
}
// 处理多模态图片 Media仅用户消息携带序列化到 metadata 供历史会话还原图片附件
if (message instanceof UserMessage userMessage) {
List<Media> mediaList = userMessage.getMedia();
if (mediaList != null && !mediaList.isEmpty()) {
List<Map<String, String>> mediaJson = new ArrayList<>();
for (Media media : mediaList) {
Map<String, String> m = new LinkedHashMap<>();
Object data = media.getData();
if (data != null) {
m.put("url", String.valueOf(data));
}
if (media.getMimeType() != null) {
m.put("mimeType", media.getMimeType().toString());
}
mediaJson.add(m);
}
metadata.put(META_KEY_MEDIA, mediaJson);
}
}
return ChatMessage.builder()
.conversationId(conversationId)
.messageType(message.getMessageType())
@ -78,15 +103,28 @@ public class MessageConverter {
: new HashMap<>();
return switch (messageType) {
case USER -> new UserMessage(text);
case USER -> {
List<Media> media = extractMedia(cleanMetadata);
yield media.isEmpty() ? new UserMessage(text)
: UserMessage.builder().text(text).media(media).build();
}
case ASSISTANT -> {
List<AssistantMessage.ToolCall> toolCalls = extractToolCalls(cleanMetadata);
yield new AssistantMessage(text, cleanMetadata, toolCalls);
// Spring AI 1.1.x AssistantMessage metadata 的构造器为 protected改用标准 builder
yield AssistantMessage.builder()
.content(text)
.properties(cleanMetadata)
.toolCalls(toolCalls)
.build();
}
case SYSTEM -> new SystemMessage(text);
case TOOL -> {
List<ToolResponseMessage.ToolResponse> responses = extractToolResponses(cleanMetadata);
yield new ToolResponseMessage(responses, cleanMetadata);
// Spring AI 1.1.x ToolResponseMessage 构造器为 protected改用标准 builder
yield ToolResponseMessage.builder()
.responses(responses)
.metadata(cleanMetadata)
.build();
}
};
}
@ -135,4 +173,43 @@ public class MessageConverter {
}
return result;
}
/**
* metadata 中提取并移除 _media 反序列化为 Media 列表
* 无媒体时返回空列表保证多轮记忆不丢图片
*/
@SuppressWarnings("unchecked")
private static List<Media> extractMedia(Map<String, Object> metadata) {
Object raw = metadata.remove(META_KEY_MEDIA);
if (!(raw instanceof List<?> list) || list.isEmpty()) {
return List.of();
}
List<Media> result = new ArrayList<>();
for (Object item : list) {
if (item instanceof Map) {
Map<?, ?> map = (Map<?, ?>) item;
Object url = map.get("url");
if (url == null) {
continue;
}
result.add(Media.builder()
.data(url.toString())
.mimeType(parseMimeTypeSafely(map.get("mimeType")))
.build());
}
}
return result;
}
/** 安全解析 MIME 类型,缺失或非法时兜底为 image/png(与图片扩展名推断逻辑一致) */
private static MimeType parseMimeTypeSafely(Object mimeType) {
if (mimeType == null) {
return MimeTypeUtils.IMAGE_PNG;
}
try {
return MimeTypeUtils.parseMimeType(mimeType.toString());
} catch (Exception e) {
return MimeTypeUtils.IMAGE_PNG;
}
}
}

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

73
src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java

@ -1,73 +0,0 @@
package com.wok.supportbot.document.extract;
import lombok.extern.slf4j.Slf4j;
import org.apache.tika.Tika;
import org.apache.tika.exception.TikaException;
import org.springframework.ai.document.Document;
import org.springframework.core.io.Resource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
@Component
@Slf4j
public class TikaDocumentReader {
/**
* 从已保存的文件解析内容
*/
public List<Document> readFromFile(File file) {
try {
Tika tika = new Tika();
String text = tika.parseToString(new java.io.FileInputStream(file));
Document doc = Document.builder()
.id(UUID.randomUUID().toString())
.text(text)
.build();
return Collections.singletonList(doc);
} catch (IOException | TikaException e) {
log.error("Tika 文件解析失败", e);
throw new RuntimeException("Tika 文件解析失败", e);
}
}
/**
* MultipartFile 解析内容兼容旧接口
*/
public List<Document> read(MultipartFile file) {
try {
Tika tika = new Tika();
String text = tika.parseToString(file.getInputStream());
Document doc = Document.builder()
.id(UUID.randomUUID().toString())
.text(text)
.build();
return Collections.singletonList(doc);
} catch (IOException | TikaException e) {
log.error("Tika 文件解析失败", e);
throw new RuntimeException("Tika 文件解析失败", e);
}
}
/**
* 获取文件扩展名含点号 ".pptx"无扩展名时返回 ".tmp"
*/
private String getExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return ".tmp";
}
return filename.substring(filename.lastIndexOf("."));
}
}

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

67
src/main/java/com/wok/supportbot/document/transform/MyTokenTextSplitter.java

@ -1,67 +0,0 @@
package com.wok.supportbot.document.transform;
import com.wok.supportbot.config.ChunkConfig;
import org.springframework.ai.document.Document;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 自定义基于 Token 的切词器
* 支持通过 ChunkConfig 动态调整分块参数
*/
@Component
public class MyTokenTextSplitter {
@Autowired
private ChunkConfig chunkConfig;
/**
* 使用全局配置参数创建分割器
*/
public List<Document> splitDocuments(List<Document> documents) {
TokenTextSplitter splitter = new TokenTextSplitter(
chunkConfig.getChunkSize(),
chunkConfig.getOverlap(),
chunkConfig.getMinChunkSizeChars(),
chunkConfig.getMaxNumChunks(),
chunkConfig.isKeepSeparator()
);
return splitter.apply(documents);
}
/**
* 使用自定义参数创建分割器覆盖全局配置
*
* @param documents 文档列表
* @param chunkSize 分块大小
* @param overlap 重叠大小
*/
public List<Document> splitDocuments(List<Document> documents, Integer chunkSize, Integer overlap) {
int cs = chunkSize != null ? chunkSize : chunkConfig.getChunkSize();
int ol = overlap != null ? overlap : chunkConfig.getOverlap();
TokenTextSplitter splitter = new TokenTextSplitter(
cs, ol,
chunkConfig.getMinChunkSizeChars(),
chunkConfig.getMaxNumChunks(),
chunkConfig.isKeepSeparator()
);
return splitter.apply(documents);
}
/**
* 使用自定义参数创建分割器全参数覆盖
*/
public List<Document> splitCustomized(List<Document> documents) {
TokenTextSplitter splitter = new TokenTextSplitter(
chunkConfig.getChunkSize(),
chunkConfig.getOverlap(),
chunkConfig.getMinChunkSizeChars(),
chunkConfig.getMaxNumChunks(),
chunkConfig.isKeepSeparator()
);
return splitter.apply(documents);
}
}

252
src/main/java/com/wok/supportbot/document/transform/OverlapTokenTextSplitter.java

@ -0,0 +1,252 @@
package com.wok.supportbot.document.transform;
import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.Encoding;
import com.knuddels.jtokkit.api.EncodingType;
import com.knuddels.jtokkit.api.IntArrayList;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.transformer.splitter.TextSplitter;
import org.springframework.util.Assert;
import java.util.ArrayList;
import java.util.List;
/**
* 带重叠overlap Token 分块器
*
* <p>Spring AI {@link org.springframework.ai.transformer.splitter.TokenTextSplitter}
* 不支持 overlap 它的 5 参构造器与 Builder 均无该形参社区 PR #4054 已标记不向 1.x 回迁
* 本类直接继承标准基类 {@link TextSplitter}复刻 TokenTextSplitter 的全部切分语义
* 标点截断最小分块长度分块数上限分隔符处理仅把前进步长 chunkSize
* 改为 {@code chunkSize - overlap}从而让重叠真正生效
*
* <p>元数据继承{@code parent_document_id} / {@code chunk_index} / {@code total_chunks}
* {@link TextSplitter} 基类统一处理本类只负责文本切分
*
* <p>Token 编码与 TokenTextSplitter 保持一致jtokkit + CL100K_BASE
*/
public class OverlapTokenTextSplitter extends TextSplitter {
private static final Logger log = LoggerFactory.getLogger(OverlapTokenTextSplitter.class);
/** 默认分块大小(token 数) */
private static final int DEFAULT_CHUNK_SIZE = 800;
/** 默认重叠 token 数(0 表示不重叠,等价于 TokenTextSplitter 行为) */
private static final int DEFAULT_OVERLAP = 0;
/** 默认最小分块字符数 */
private static final int MIN_CHUNK_SIZE_CHARS = 350;
/** 默认丢弃阈值:长度不超过该值的分块不入库 */
private static final int MIN_CHUNK_LENGTH_TO_EMBED = 5;
/** 默认单篇文档最大分块数 */
private static final int MAX_NUM_CHUNKS = 10000;
/** 默认保留分隔符 */
private static final boolean KEEP_SEPARATOR = true;
private final Encoding encoding = Encodings.newLazyEncodingRegistry().getEncoding(EncodingType.CL100K_BASE);
/** 目标分块大小(token 数,不含重叠部分) */
private final int chunkSize;
/** 相邻分块的重叠 token 数 */
private final int overlap;
/** 最小分块字符数:仅当剩余 token 数超过 chunkSize 时才按标点截断,且截断点须大于该值 */
private final int minChunkSizeChars;
/** 丢弃长度不超过该值的分块 */
private final int minChunkLengthToEmbed;
/** 单篇文档最大分块数 */
private final int maxNumChunks;
/** 是否保留分隔符(false 时把换行替换为空格) */
private final boolean keepSeparator;
private OverlapTokenTextSplitter(int chunkSize, int overlap, int minChunkSizeChars,
int minChunkLengthToEmbed, int maxNumChunks, boolean keepSeparator) {
Assert.isTrue(chunkSize > 0, "chunkSize 必须大于 0");
this.chunkSize = chunkSize;
// 重叠必须严格小于分块大小否则前进步长会 <= 0 导致死循环此处收敛到合法区间而非直接报错
int clamped = Math.max(0, Math.min(overlap, chunkSize - 1));
if (clamped != overlap) {
log.warn("overlap({}) 超出合法区间 [0, chunkSize-1={}],已收敛为 {};"
+ "该配置会使前进步长退化为 1 个 token,产生大量分块,请检查 knowledge.chunk.overlap",
overlap, chunkSize - 1, clamped);
}
this.overlap = clamped;
this.minChunkSizeChars = minChunkSizeChars;
this.minChunkLengthToEmbed = minChunkLengthToEmbed;
this.maxNumChunks = maxNumChunks;
this.keepSeparator = keepSeparator;
}
public static Builder builder() {
return new Builder();
}
/**
* 标点截断允许的最小前进步长token
* <p>
* 防止标点截断overlap叠加后把前进步长压到 1 token 导致分块数爆炸
* 截断后必须仍能前进这么多 token否则放弃本次截断宁可切断句子也要保证分块数量可控
* <p>
* overlap=0 时返回 1即允许任意截断与标准 {@code TokenTextSplitter} 行为完全一致
*/
private int minAdvance() {
if (this.overlap == 0) {
return 1;
}
return Math.max(1, (this.chunkSize - this.overlap) / 2);
}
@Override
protected List<String> splitText(String text) {
if (text == null || text.trim().isEmpty()) {
return new ArrayList<>();
}
List<Integer> tokens = getEncodedTokens(text);
List<String> chunks = new ArrayList<>();
int numChunks = 0;
while (!tokens.isEmpty() && numChunks < this.maxNumChunks) {
int windowSize = Math.min(this.chunkSize, tokens.size());
boolean lastWindow = windowSize >= tokens.size();
String chunkText = decodeTokens(tokens.subList(0, windowSize));
// 空白块直接跳过不产出也不做重叠回退
if (chunkText.trim().isEmpty()) {
tokens = tokens.subList(windowSize, tokens.size());
continue;
}
// 仅当剩余 token 数超过 chunkSize 时才做标点截断避免小文本被无谓切分
if (tokens.size() > this.chunkSize) {
int lastPunctuation = Math.max(chunkText.lastIndexOf('.'),
Math.max(chunkText.lastIndexOf('?'),
Math.max(chunkText.lastIndexOf('!'), chunkText.lastIndexOf('\n'))));
if (lastPunctuation != -1 && lastPunctuation > this.minChunkSizeChars) {
String candidate = chunkText.substring(0, lastPunctuation + 1);
// 前进步长 = 截断后消耗的 token - overlap截断会缩短消耗量
// 若不加约束截断点靠前时步长会被压到 1 token分块数成倍膨胀
// 实测 chunkSize=60/overlap=30 时可达 10 此处要求截断后仍能前进至少 minAdvance()
if (getEncodedTokens(candidate).size() - this.overlap >= minAdvance()) {
chunkText = candidate;
}
}
}
String chunkTextToAppend = this.keepSeparator
? chunkText.trim()
: chunkText.replace(System.lineSeparator(), " ").trim();
if (chunkTextToAppend.length() > this.minChunkLengthToEmbed) {
chunks.add(chunkTextToAppend);
}
numChunks++;
// 本窗口已覆盖全部剩余 token无需再产出重叠块
if (lastWindow) {
tokens = new ArrayList<>();
break;
}
// 本块实际消耗的 token 可能因标点截断而少于窗口大小
int consumed = getEncodedTokens(chunkText).size();
// 前进步长 = 消耗量 - 重叠量至少前进 1 token避免死循环
int step = Math.max(1, consumed - this.overlap);
tokens = tokens.subList(step, tokens.size());
}
// 处理剩余 token TokenTextSplitter 保持一致用于达到 maxNumChunks 上限的场景
if (!tokens.isEmpty()) {
String remainingText = decodeTokens(tokens).replace(System.lineSeparator(), " ").trim();
if (remainingText.length() > this.minChunkLengthToEmbed) {
chunks.add(remainingText);
}
}
return chunks;
}
private List<Integer> getEncodedTokens(String text) {
Assert.notNull(text, "Text must not be null");
return this.encoding.encode(text).boxed();
}
private String decodeTokens(List<Integer> tokens) {
Assert.notNull(tokens, "Tokens must not be null");
IntArrayList tokensIntArray = new IntArrayList(tokens.size());
tokens.forEach(tokensIntArray::add);
return this.encoding.decode(tokensIntArray);
}
/**
* 分块器构建器
*/
public static final class Builder {
private int chunkSize = DEFAULT_CHUNK_SIZE;
private int overlap = DEFAULT_OVERLAP;
private int minChunkSizeChars = MIN_CHUNK_SIZE_CHARS;
private int minChunkLengthToEmbed = MIN_CHUNK_LENGTH_TO_EMBED;
private int maxNumChunks = MAX_NUM_CHUNKS;
private boolean keepSeparator = KEEP_SEPARATOR;
private Builder() {
}
/** 目标分块大小(token 数) */
public Builder withChunkSize(int chunkSize) {
this.chunkSize = chunkSize;
return this;
}
/** 相邻分块的重叠 token 数 */
public Builder withOverlap(int overlap) {
this.overlap = overlap;
return this;
}
/** 最小分块字符数(标点截断的下限) */
public Builder withMinChunkSizeChars(int minChunkSizeChars) {
this.minChunkSizeChars = minChunkSizeChars;
return this;
}
/** 丢弃长度不超过该值的分块 */
public Builder withMinChunkLengthToEmbed(int minChunkLengthToEmbed) {
this.minChunkLengthToEmbed = minChunkLengthToEmbed;
return this;
}
/** 单篇文档最大分块数 */
public Builder withMaxNumChunks(int maxNumChunks) {
this.maxNumChunks = maxNumChunks;
return this;
}
/** 是否保留分隔符 */
public Builder withKeepSeparator(boolean keepSeparator) {
this.keepSeparator = keepSeparator;
return this;
}
public OverlapTokenTextSplitter build() {
return new OverlapTokenTextSplitter(this.chunkSize, this.overlap, this.minChunkSizeChars,
this.minChunkLengthToEmbed, this.maxNumChunks, this.keepSeparator);
}
}
}

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

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

@ -1,6 +1,7 @@
package com.wok.supportbot.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.wok.supportbot.handler.PostgresJsonTypeHandler;
@ -63,9 +64,14 @@ public class KnowledgeDocument implements Serializable {
private String filePath;
/**
* 原文内容(截断预览)
* 原文全文用于重新处理时重建分块
* <p>
* {@code @JsonIgnore}全文可达数十万字符**任何接口都不序列化**避免列表/回显接口携带整篇正文
* 文档详情接口DocumentController.getDocumentDetail单独组装响应返回截断后的预览
* 键名仍为 {@code content} {@code contentTruncated} 标志
*/
@TableField("content")
@JsonIgnore
private String content;
/**
@ -75,6 +81,13 @@ public class KnowledgeDocument implements Serializable {
@JsonSerialize(using = ToStringSerializer.class)
private Long categoryId;
/**
* 所属目录ID - 0表示未指定目录(直接挂分类根)
*/
@TableField("folder_id")
@JsonSerialize(using = ToStringSerializer.class)
private Long folderId;
/**
* 标签列表(JSON数组)
*/

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

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

4
src/main/java/com/wok/supportbot/entity/SysUser.java

@ -60,6 +60,10 @@ public class SysUser implements Serializable {
@TableField("enabled")
private Boolean enabled;
/** JWT 令牌版本号,改密/禁用/角色变更时自增,用于服务端撤销已签发 token */
@TableField("token_version")
private Integer tokenVersion;
/** 最后登录时间 */
@TableField("last_login_time")
private Date lastLoginTime;

5
src/main/java/com/wok/supportbot/handler/MyMetaObjectHandler.java

@ -20,6 +20,9 @@ public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date());
// 使用 setFieldValByName 而非 strictUpdateFill
// strictUpdateFill 仅匹配 FieldFill.UPDATE update_time FieldFill.INSERT_UPDATE
// 导致 updateById update_time 不会被自动填充实测 token_version 自增但 update_time 不更新
this.setFieldValByName("updateTime", new Date(), metaObject);
}
}

36
src/main/java/com/wok/supportbot/mcp/McpToolCallback.java

@ -37,8 +37,6 @@ public class McpToolCallback implements ToolCallback {
*/
private static final int MAX_LOG_LENGTH = 1000;
// ==================== 工具调用事件收集 ====================
/**
* 工具调用事件记录
*/
@ -55,29 +53,6 @@ public class McpToolCallback implements ToolCallback {
*/
public static final String MCP_ROUNDS_KEY = "mcp_tool_rounds";
/**
* 线程级事件收集器在同一请求线程中收集所有工具调用事件
* 作为无 toolContext 场景同步调用 / 兼容旧逻辑的兜底
*/
private static final ThreadLocal<List<ToolCallEvent>> EVENTS = ThreadLocal.withInitial(ArrayList::new);
/**
* 获取当前线程收集的所有工具调用事件并清空收集器
*/
public static List<ToolCallEvent> drainEvents() {
List<ToolCallEvent> events = EVENTS.get();
List<ToolCallEvent> copy = new ArrayList<>(events);
events.clear();
return copy;
}
/**
* 重置事件收集器每个新请求开始时调用
*/
public static void resetEvents() {
EVENTS.remove();
}
/**
* MCP Server 配置 ID用于路由调用到正确的 MCP Client
*/
@ -184,20 +159,21 @@ public class McpToolCallback implements ToolCallback {
*/
@Override
public String call(String toolInput) {
// ToolContext 时回退到 ThreadLocal 收集器同步调用 / 兼容旧逻辑
return doCall(toolInput, EVENTS.get(), null);
// 接口要求的单参重载 ToolContext 时事件无处可传用一次性列表承接后丢弃
// 生产路径始终走 call(String, ToolContext) AssistantApp 注入收集器
return doCall(toolInput, new ArrayList<>(), null);
}
/**
* ToolContext 的执行入口
* <p>
* 优先 ToolContext 读取 AssistantApp 注入的事件收集器与轮次计数器
* 解决 Reactor 流式场景下 ThreadLocal 跨线程丢失的问题读取不到时回退 ThreadLocal
* ToolContext 读取 AssistantApp 注入的事件收集器与轮次计数器
* 解决 Reactor 流式场景下 ThreadLocal 跨线程丢失的问题读取不到时事件被丢弃轮次回退线程级计数
*/
@Override
@SuppressWarnings("unchecked")
public String call(String toolInput, ToolContext toolContext) {
List<ToolCallEvent> collector = EVENTS.get();
List<ToolCallEvent> collector = new ArrayList<>();
AtomicInteger rounds = null;
Map<String, Object> ctx = toolContext != null ? toolContext.getContext() : null;
if (ctx != null) {

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save