6 Commits

Author SHA1 Message Date
wanghanlin d33cb56e8a 优化 1 month ago
wanghanlin 0838dca61a fix(auth): 修复后台会话长期不失效,JWT 时间配置改用可读单位 1 month ago
wanghanlin 2001dc3a0f fix(doc): 修复重新处理文档会按 2000 字截断预览重建导致内容丢失 1 month ago
wanghanlin 94c1a074e7 优化 2 months ago
wanghanlin 84eea7564f refactor: 自造轮子改用 Spring AI 标准组件、清理死代码并修复既有缺陷 2 months ago
wanghanlin 1731a3ca60 chore(deps): 升级 Spring Boot 3.5.8 / Spring AI 1.1.2 / Spring AI Alibaba 1.1.2.2 2 months ago
  1. 50
      CLAUDE.md
  2. 2
      DEPLOY.md
  3. 33
      README.md
  4. 9
      frontend/src/App.vue
  5. 2
      frontend/src/views/DocDetail.vue
  6. 65
      pom.xml
  7. 5
      src/main/java/com/wok/supportbot/SupportBotApplication.java
  8. 43
      src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java
  9. 5
      src/main/java/com/wok/supportbot/app/ChatResult.java
  10. 31
      src/main/java/com/wok/supportbot/app/SuggestionGenerator.java
  11. 294
      src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java
  12. 13
      src/main/java/com/wok/supportbot/auth/AuthController.java
  13. 85
      src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java
  14. 4
      src/main/java/com/wok/supportbot/config/ChatModelFactory.java
  15. 2
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  16. 12
      src/main/java/com/wok/supportbot/controller/AuthController.java
  17. 31
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  18. 14
      src/main/java/com/wok/supportbot/controller/McpServerConfigController.java
  19. 13
      src/main/java/com/wok/supportbot/converter/MessageConverter.java
  20. 73
      src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java
  21. 67
      src/main/java/com/wok/supportbot/document/transform/MyTokenTextSplitter.java
  22. 252
      src/main/java/com/wok/supportbot/document/transform/OverlapTokenTextSplitter.java
  23. 8
      src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java
  24. 36
      src/main/java/com/wok/supportbot/mcp/McpToolCallback.java
  25. 73
      src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java
  26. 50
      src/main/java/com/wok/supportbot/rag/RerankerService.java
  27. 21
      src/main/java/com/wok/supportbot/security/JwtTokenProvider.java
  28. 28
      src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java
  29. 34
      src/main/java/com/wok/supportbot/service/DocumentProcessingService.java
  30. 100
      src/main/java/com/wok/supportbot/service/DocumentService.java
  31. 100
      src/main/java/com/wok/supportbot/service/IntentRouter.java
  32. 15
      src/main/resources/application.yml
  33. 2
      src/main/resources/init-database.sql
  34. 2
      src/main/resources/knowledge-base.sql

50
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,10 +67,22 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- **Open API**: `OpenApiController` 已接入 `ChatPipeline`,补齐角色/RAG/FAQ/MCP/分类隔离能力
### 文档处理管道
`DocumentService.uploadDocument()` 统一流程:文档提取 → `MyTokenTextSplitter` 分块 → 为每块写 metadata → 按批向量化(默认 50 块/批,配置项 `knowledge.vector.batch-size`)`pgVectorVectorStore.add(batch)` 入库。每个分块的 metadata 注入 `documentId`、`chunkIndex`、`sourceName`、`title`、`categoryId`、`enabled` 关联 `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)。新文档走库内全文分支,语义不变。
## 关键配置
@ -78,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` 为准
@ -94,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 中配置的提供商和模型
@ -316,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)
@ -326,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` 增加检索模式下拉选择(向量/关键词/混合),结果标注来源模式
@ -342,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)编写。*

33
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,13 +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 分块器
│ └── OverlapTokenTextSplitter.java # 带重叠的 Token 分块器(继承标准 TextSplitter)
├── entity/ # 数据实体类
│ ├── ChatMessage.java # 聊天消息实体
│ ├── KnowledgeDocument.java # 知识文档实体
@ -229,9 +226,9 @@ src/main/resources/
```
上传文件/文本
↓
[文档提取器] Tika / Markdown / JSON / 纯文本
[文档提取器] Tika(官方 TikaDocumentReader)/ Markdown / JSON / 纯文本
↓
[Token 分块] MyTokenTextSplitter (200 token / 100 overlap)
[Token 分块] OverlapTokenTextSplitter (chunkSize=200, overlap=100)
↓
[元数据标注] metadata.documentId / chunkIndex / sourceName / title / categoryId / enabled
↓

9
frontend/src/App.vue

@ -33,10 +33,15 @@ onMounted(async () => {
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 {
await auth.doLogout()
}

2
frontend/src/views/DocDetail.vue

@ -26,7 +26,7 @@
<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>

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、
* EmbeddingModelFactory、PgVectorStoreConfig),不使用 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;
}
}

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 代码块或序号(输出格式由系统统一约束)";
}
/**

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

@ -1,294 +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();
// 剥离旧版哨兵标记(___SUGGESTIONS___ 及兼容大写变形),取哨兵后的 JSON 部分。
// 旧版 suggestion_prompt 会要求模型输出哨兵,若未剥离会被按行降级解析成垃圾条目。
int sentinelIdx = findSentinelIndex(trimmed);
if (sentinelIdx >= 0) {
int matchedLen = trimmed.startsWith(SENTINEL, sentinelIdx)
? SENTINEL.length()
: LOOSE_SENTINEL.length();
trimmed = trimmed.substring(sentinelIdx + matchedLen).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 建议问题列表(0~3 条)
*/
public record ParsedResponse(String answer, List<String> suggestions) {
}
}

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

@ -103,7 +103,7 @@ public class AuthController {
loginAttemptService.recordSuccess(username);
Map<String, Object> userInfo = buildUserInfo(user, roleKeys);
ResponseCookie refreshCookie = buildRefreshCookie(refreshToken, 7 * 24 * 60 * 60L, request.isSecure());
ResponseCookie refreshCookie = buildRefreshCookie(refreshToken, refreshCookieMaxAgeSeconds(), request.isSecure());
log.info("用户登录成功: {}", username);
return ResponseEntity.ok()
@ -164,7 +164,7 @@ public class AuthController {
String newAccessToken = jwtTokenProvider.generateToken(username, roleKeys, currentVer);
String newRefreshToken = jwtTokenProvider.generateRefreshToken(username, currentVer);
ResponseCookie refreshCookie = buildRefreshCookie(newRefreshToken, 7 * 24 * 60 * 60L, request.isSecure());
ResponseCookie refreshCookie = buildRefreshCookie(newRefreshToken, refreshCookieMaxAgeSeconds(), request.isSecure());
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
@ -279,6 +279,15 @@ public class AuthController {
return userInfo;
}
/**
* refresh cookie 的 Max-Age(秒)
* 直接取自 jwt.refresh-expiration 配置,保证 Cookie 生命周期与 refresh token 完全一致,
* 避免两处硬编码后「改配置不改 Cookie」导致的有效期漂移
*/
private long refreshCookieMaxAgeSeconds() {
return jwtTokenProvider.getRefreshExpirationSeconds();
}
/**
* 构建 refresh token Cookie。
* Secure 属性按「配置开关 && 当前请求是否 HTTPS」动态决定:

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

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

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

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

@ -1500,7 +1500,7 @@ 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 格式)");

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 JWT(subject = apiKeyId)
String token = sdkJwtTokenProvider.generateToken(

31
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;
// ==================== 上传校验常量 ====================
@ -384,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(
@ -394,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;
}
/**
* 获取文档的所有分块
*/

14
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;
// ==================== 分页列表 ====================
/**
@ -161,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,
@ -198,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,
@ -231,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", "配置删除成功"
@ -282,6 +292,8 @@ public class McpServerConfigController {
} else {
mcpClientManager.disableClient(id);
}
// MCP 工具清单可能变化,清空 AssistantApp 的 ChatClient 缓存,避免继续使用旧工具集
assistantApp.clearCache();
return ResponseEntity.ok(Map.of(
"success", true,
"message", active ? "配置已启用" : "配置已禁用"
@ -335,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 客户端连接已刷新"

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

@ -110,12 +110,21 @@ public class MessageConverter {
}
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();
}
};
}

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

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

8
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;
/**

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

73
src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java

@ -1,73 +0,0 @@
package com.wok.supportbot.mcp;
import org.springframework.http.codec.ServerSentEvent;
/**
* SSE 事件构建器
* 用于在 MCP 工具调用流程中构建标准 SSE 事件,
* 让前端能区分文本内容与工具调用状态。
*
* 事件类型:
* - message: 普通文本内容
* - tool_call_start: 工具调用开始
* - tool_call_result: 工具调用结果返回
* - error: 错误信息
*/
public class SseEventBuilder {
/**
* 构建普通文本消息事件
*/
public static ServerSentEvent<String> messageEvent(String data) {
return ServerSentEvent.<String>builder()
.event("message")
.data(data)
.build();
}
/**
* 构建工具调用开始事件
*/
public static ServerSentEvent<String> toolCallStartEvent(String toolName, String input) {
String json = String.format("{\"tool\":\"%s\",\"input\":\"%s\"}",
escapeJson(toolName), escapeJson(input));
return ServerSentEvent.<String>builder()
.event("tool_call_start")
.data(json)
.build();
}
/**
* 构建工具调用结果事件
*/
public static ServerSentEvent<String> toolCallResultEvent(String toolName, String result, long latencyMs) {
String json = String.format("{\"tool\":\"%s\",\"result\":\"%s\",\"latencyMs\":%d}",
escapeJson(toolName), escapeJson(result), latencyMs);
return ServerSentEvent.<String>builder()
.event("tool_call_result")
.data(json)
.build();
}
/**
* 构建错误事件
*/
public static ServerSentEvent<String> errorEvent(String message) {
return ServerSentEvent.<String>builder()
.event("error")
.data("{\"message\":\"" + escapeJson(message) + "\"}")
.build();
}
/**
* JSON 字符串转义
*/
private static String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
}

50
src/main/java/com/wok/supportbot/rag/RerankerService.java

@ -7,8 +7,10 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.client.RestClient;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@ -26,9 +28,29 @@ public class RerankerService {
@Autowired
private AiModelConfigMapper aiModelConfigMapper;
/** HTTP 超时时间(秒) */
/** HTTP 读超时(秒):与下方 RestClient 的 read timeout 一致,超过即 fallback 到 RRF 原始排序 */
private static final int TIMEOUT_SECONDS = 3;
/** HTTP 连接超时(秒):Rerank 在检索链路内同步调用,必须快速失败 */
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
/**
* 带超时的 HTTP 客户端。
* <p>
* 原实现用 {@code new RestTemplate()}(无任何超时),慢 provider 会把检索线程拖到 TCP 超时。
* 此处改用 RestClient + JdkClientHttpRequestFactory 显式设置 connect/read 超时。
*/
private final RestClient restClient;
public RerankerService() {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(Duration.ofSeconds(TIMEOUT_SECONDS));
this.restClient = RestClient.builder().requestFactory(requestFactory).build();
}
/** RERANK 活跃配置本地缓存,避免每次 Rerank 调用都查询 ai_model_config 表 */
private volatile AiModelConfig cachedRerankConfig;
private volatile long lastCacheTime = 0;
@ -132,10 +154,8 @@ public class RerankerService {
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(config.getApiKey());
ResponseEntity<Map> response = postWithTimeout(url, headers, body);
// 解析响应:{"output":{"results":[{"index":0,"relevance_score":0.95},...]}}
Map responseBody = response.getBody();
Map responseBody = postWithTimeout(url, headers, body);
if (responseBody == null) {
throw new RuntimeException("DashScope Rerank 响应为空");
}
@ -183,10 +203,8 @@ public class RerankerService {
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(config.getApiKey());
ResponseEntity<Map> response = postWithTimeout(url, headers, body);
// 解析响应:{"results":[{"index":0,"relevance_score":0.95},...]}
Map responseBody = response.getBody();
Map responseBody = postWithTimeout(url, headers, body);
if (responseBody == null) {
throw new RuntimeException("OpenAI 兼容 Rerank 响应为空");
}
@ -227,15 +245,17 @@ public class RerankerService {
}
/**
* 带超时的 HTTP POST 请求
* 带 connect/read 超时的 HTTP POST 请求。
* 使用 JDK HttpClient + JdkClientHttpRequestFactory,超时后抛异常由调用方 fallback 到 RRF 原始排序。
*/
@SuppressWarnings("unchecked")
private ResponseEntity<Map> postWithTimeout(String url, HttpHeaders headers, Map<String, Object> body) {
RestTemplate restTemplate = new RestTemplate();
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
// RestTemplate 默认无超时,此处依赖连接/读取超时由底层控制
// Spring Boot 3.x 中可使用 RestClient 替代以获得更好的超时支持
return restTemplate.exchange(url, HttpMethod.POST, entity, Map.class);
private Map<String, Object> postWithTimeout(String url, HttpHeaders headers, Map<String, Object> body) {
return restClient.post()
.uri(url)
.headers(h -> h.addAll(headers))
.body(body)
.retrieve()
.body(Map.class);
}
/**

21
src/main/java/com/wok/supportbot/security/JwtTokenProvider.java

@ -10,6 +10,7 @@ import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Date;
import java.util.List;
@ -25,14 +26,14 @@ public class JwtTokenProvider {
public static final String DEFAULT_SECRET = "support-bot-jwt-secret-key-2026-please-change-in-production";
private final SecretKey key;
private final long expiration;
private final long refreshExpiration;
private final Duration expiration;
private final Duration refreshExpiration;
private final boolean failIfDefaultSecret;
public JwtTokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration:900000}") long expiration,
@Value("${jwt.refresh-expiration:604800000}") long refreshExpiration,
@Value("${jwt.expiration:15m}") Duration expiration,
@Value("${jwt.refresh-expiration:8h}") Duration refreshExpiration,
@Value("${jwt.fail-if-default-secret:false}") boolean failIfDefaultSecret) {
if (DEFAULT_SECRET.equals(secret)) {
String message = "⚠️ 管理后台 JWT 使用了默认密钥,存在安全风险!请在 application.yml 中配置 jwt.secret 为强随机字符串(建议通过环境变量 JWT_SECRET 注入)";
@ -63,7 +64,7 @@ public class JwtTokenProvider {
.claim("type", "access")
.claim("ver", tokenVersion)
.issuedAt(now)
.expiration(new Date(now.getTime() + expiration))
.expiration(new Date(now.getTime() + expiration.toMillis()))
.signWith(key)
.compact();
}
@ -78,11 +79,19 @@ public class JwtTokenProvider {
.claim("type", "refresh")
.claim("ver", tokenVersion)
.issuedAt(now)
.expiration(new Date(now.getTime() + refreshExpiration))
.expiration(new Date(now.getTime() + refreshExpiration.toMillis()))
.signWith(key)
.compact();
}
/**
* 刷新令牌有效期(秒)
* 供 refresh Cookie 的 Max-Age 复用,保证 Cookie 与 refresh token 的生命周期始终一致
*/
public long getRefreshExpirationSeconds() {
return refreshExpiration.toSeconds();
}
/**
* 验证 access token 有效性(校验签名、过期时间,且 type 必须为 access)
*/

28
src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java

@ -12,6 +12,7 @@ import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Date;
import java.util.List;
import java.util.Set;
@ -31,19 +32,19 @@ import java.util.stream.Collectors;
public class SdkJwtTokenProvider {
private final SecretKey key;
private final long defaultExpiration;
private final Duration defaultExpiration;
/** 24 小时上限 */
private static final long MAX_EXPIRATION = 86400000L;
private static final Duration MAX_EXPIRATION = Duration.ofDays(1);
/** 5 分钟下限 */
private static final long MIN_EXPIRATION = 300000L;
private static final Duration MIN_EXPIRATION = Duration.ofMinutes(5);
/** 默认密钥标识(禁止使用),长度 ≥64 字符以兼容 HS256/HS384/HS512,且必须与 application.yml 默认值一致 */
private static final String DEFAULT_SECRET = "support-bot-sdk-jwt-secret-2026-please-change";
public SdkJwtTokenProvider(
@Value("${jwt.sdk-secret:support-bot-sdk-jwt-secret-2026-please-change}") String sdkSecret,
@Value("${jwt.sdk-expiration:7200000}") long defaultExpiration,
@Value("${jwt.sdk-expiration:2h}") Duration defaultExpiration,
@Value("${jwt.fail-if-default-sdk-secret:false}") boolean failIfDefaultSdkSecret) {
// 空值校验:密钥为空/空白时启动失败并给出明确提示,避免 Keys.hmacShaKeyFor 抛出晦涩异常
if (!StringUtils.hasText(sdkSecret)) {
@ -70,7 +71,7 @@ public class SdkJwtTokenProvider {
* @return JWT Token 字符串
*/
public String generateToken(String apiKeyId, String maskedApiKey, List<Long> roleIds, long expirationMs) {
long clampedExpiration = clampExpiration(expirationMs);
long clampedExpiration = clampExpirationMillis(expirationMs);
Date now = new Date();
return Jwts.builder()
.subject(apiKeyId)
@ -86,7 +87,15 @@ public class SdkJwtTokenProvider {
* 使用默认过期时间签发 SDK JWT Token
*/
public String generateToken(String apiKeyId, String maskedApiKey, List<Long> roleIds) {
return generateToken(apiKeyId, maskedApiKey, roleIds, defaultExpiration);
return generateToken(apiKeyId, maskedApiKey, roleIds, defaultExpiration.toMillis());
}
/**
* 默认有效期(毫秒),取自 jwt.sdk-expiration 配置
* 供 SDK 版 AuthController 在调用方未指定 ttl 时作为默认值使用
*/
public long getDefaultExpirationMillis() {
return defaultExpiration.toMillis();
}
/**
@ -151,9 +160,10 @@ public class SdkJwtTokenProvider {
}
/**
* 将过期时间钳制到 [5min, 24h] 范围内
* 将过期时间钳制到 [5min, 24h] 范围内(毫秒)
* 公开供 SDK 版 AuthController 复用,使钳制边界只有一处定义,避免响应值与 token 实际有效期不一致
*/
private long clampExpiration(long expirationMs) {
return Math.max(MIN_EXPIRATION, Math.min(expirationMs, MAX_EXPIRATION));
public long clampExpirationMillis(long expirationMs) {
return Math.max(MIN_EXPIRATION.toMillis(), Math.min(expirationMs, MAX_EXPIRATION.toMillis()));
}
}

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

@ -1,8 +1,9 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.wok.supportbot.config.ChunkConfig;
import com.wok.supportbot.dao.KnowledgeDocumentMapper;
import com.wok.supportbot.document.transform.MyTokenTextSplitter;
import com.wok.supportbot.document.transform.OverlapTokenTextSplitter;
import com.wok.supportbot.entity.KnowledgeDocument;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
@ -32,6 +33,12 @@ public class DocumentProcessingService {
@Value("${knowledge.vector.batch-size:50}")
private int embedBatchSize = 50;
/**
* 分块丢弃阈值(字符):长度不超过该值的分块不入库。
* ChunkConfig 无对应配置项,此处沿用历史生效值(旧实现因形参错位,实际也是 10)。
*/
private static final int MIN_CHUNK_LENGTH_TO_EMBED = 10;
/**
* 文档级自动重试次数(不含首次尝试)。
* 注意:批内 EmbeddingModel 已自带 3 次指数退避重试(EmbeddingModelFactory.createRetryTemplate),
@ -50,7 +57,7 @@ public class DocumentProcessingService {
private KnowledgeDocumentMapper documentMapper;
@Autowired
private MyTokenTextSplitter myTokenTextSplitter;
private ChunkConfig chunkConfig;
@Autowired
private VectorStore pgVectorVectorStore;
@ -88,7 +95,7 @@ public class DocumentProcessingService {
// 新文档首次处理无需清理(尚无向量);若中途失败触发整体重试,框架内会先清残留向量再重建
runPipelineWithRetry(doc, meta, false,
() -> myTokenTextSplitter.splitDocuments(documents, chunkSize, overlap));
() -> buildSplitter(chunkSize, overlap).apply(documents));
}
/**
@ -117,10 +124,29 @@ public class DocumentProcessingService {
if (doc.getExtraConfig().get("chunkSize") instanceof Number cs) chunkSize = cs.intValue();
if (doc.getExtraConfig().get("overlap") instanceof Number ol) overlap = ol.intValue();
}
return myTokenTextSplitter.splitDocuments(documents, chunkSize, overlap);
return buildSplitter(chunkSize, overlap).apply(documents);
});
}
/**
* 构建分块器:per-doc 参数优先,缺省回退全局 ChunkConfig。
*
* @param chunkSize 分块大小(token 数,可为 null)
* @param overlap 重叠大小(token 数,可为 null)
*/
private OverlapTokenTextSplitter buildSplitter(Integer chunkSize, Integer overlap) {
int cs = chunkSize != null ? chunkSize : chunkConfig.getChunkSize();
int ol = overlap != null ? overlap : chunkConfig.getOverlap();
return OverlapTokenTextSplitter.builder()
.withChunkSize(cs)
.withOverlap(ol)
.withMinChunkSizeChars(chunkConfig.getMinChunkSizeChars())
.withMinChunkLengthToEmbed(MIN_CHUNK_LENGTH_TO_EMBED)
.withMaxNumChunks(chunkConfig.getMaxNumChunks())
.withKeepSeparator(chunkConfig.isKeepSeparator())
.build();
}
/**
* 文档级处理框架(外层兜底):切分 → 分批向量化 → 失败自动整体重试有限次数。
* 外层 try-catch 保证任何未预期异常下文档状态都从 PROCESSING 收敛到 FAILED,

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

@ -6,8 +6,6 @@ import com.wok.supportbot.dao.KnowledgeDocumentMapper;
import com.wok.supportbot.document.extract.JsonDocumentLoader;
import com.wok.supportbot.document.extract.MarkdownDocumentLoader;
import com.wok.supportbot.document.extract.SimpleStringDocumentReader;
import com.wok.supportbot.document.extract.TikaDocumentReader;
import com.wok.supportbot.document.transform.MyTokenTextSplitter;
import com.wok.supportbot.entity.CategoryNode;
import com.wok.supportbot.entity.KnowledgeCategory;
import com.wok.supportbot.entity.KnowledgeDocument;
@ -15,13 +13,16 @@ import com.wok.supportbot.entity.KnowledgeFolder;
import com.wok.supportbot.entity.SearchResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -52,12 +53,6 @@ public class DocumentService {
@Autowired
private VectorStore pgVectorVectorStore;
@Autowired
private MyTokenTextSplitter myTokenTextSplitter;
@Autowired
private TikaDocumentReader tikaDocumentReader;
@Autowired
private SimpleStringDocumentReader simpleStringDocumentReader;
@ -128,6 +123,9 @@ public class DocumentService {
Map<String, Object> extraConfig = new HashMap<>();
if (chunkSize != null) extraConfig.put("chunkSize", chunkSize);
if (overlap != null) extraConfig.put("overlap", overlap);
// 完整性标记:本字段起 content 存全文(不再截断),可安全用于 reprocess。
// 历史数据无此标记(其 content 是 2000 字截断预览),reprocess 据此走「从原始文件重解析」或明确拒绝。
extraConfig.put("contentComplete", true);
// 2. 创建文档记录(状态 PROCESSING)
KnowledgeDocument docRecord = KnowledgeDocument.builder()
@ -136,13 +134,13 @@ public class DocumentService {
.fileType(fileType)
.fileSize(fileSize != null ? fileSize : 0L)
.filePath(filePath)
.content(content != null && content.length() > 2000 ? content.substring(0, 2000) : content)
.content(content)
.categoryId(categoryId != null ? categoryId : 0L)
.folderId(folderId != null ? folderId : 0L)
.tags(tags != null ? Map.of("tags", tags) : null)
.contentHash(contentHash)
.enabled(true)
.extraConfig(extraConfig.isEmpty() ? null : extraConfig)
.extraConfig(extraConfig)
.status("PROCESSING")
.chunkCount(0)
.build();
@ -167,7 +165,8 @@ public class DocumentService {
java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile();
// 2. 使用保存的文件进行解析(避免 MultipartFile 的临时文件问题)
List<Document> documents = tikaDocumentReader.readFromFile(savedFile);
// 使用 Spring AI 官方 TikaDocumentReader:get() 内部为 List.of(toDocument(...)),恒定返回 1 个 Document
List<Document> documents = new TikaDocumentReader(new FileSystemResource(savedFile)).get();
// 剥离路径前缀:文件夹上传时浏览器 filename 携带相对路径(含根目录名),否则标题会带「目录/」前缀
String sourceName = stripPath(file.getOriginalFilename());
String fileType = getFileExtension(sourceName);
@ -668,6 +667,9 @@ public class DocumentService {
/**
* 重新处理文档(异步:先标记为 PROCESSING,后台重新分块 + 向量化)
* <p>
* 数据源解析见 {@link #loadSourceDocuments}:优先用库内全文,历史遗留文档回退到原始文件重解析,
* 两者都不可用时明确报错拒绝 —— 绝不静默按截断预览重建(会先删旧向量,属于不可逆的数据丢失)。
*/
@Transactional(rollbackFor = Exception.class)
public KnowledgeDocument reprocessDocument(Long id) {
@ -675,8 +677,20 @@ public class DocumentService {
if (doc == null) {
throw new RuntimeException("文档不存在");
}
if (doc.getContent() == null || doc.getContent().isEmpty()) {
throw new RuntimeException("文档无内容,无法重新处理");
// 先解析数据源:无法完整还原时直接抛错(事务回滚),不会破坏已有向量与文档状态
SourceLoad loaded = loadSourceDocuments(doc);
List<Document> documents = loaded.documents();
// 历史遗留文档从原始文件重解析成功后,把全文回填进 content 并打上完整性标记:
// 该文档此后不再依赖原始文件(文件丢失也能重跑),详情接口的 contentTruncated 也恢复准确
if (loaded.reparsedFromFile()) {
doc.setContent(documents.stream().map(Document::getText).collect(Collectors.joining("\n")));
Map<String, Object> extra = doc.getExtraConfig() != null
? new HashMap<>(doc.getExtraConfig())
: new HashMap<>();
extra.put("contentComplete", true);
doc.setExtraConfig(extra);
}
// 标记为 PROCESSING,清空旧状态
@ -685,14 +699,70 @@ public class DocumentService {
doc.setErrorMessage(null);
documentMapper.updateById(doc);
// 解析内容并触发异步处理
List<Document> documents = simpleStringDocumentReader.read(doc.getContent());
// 触发异步处理
documentProcessingService.reprocessDocumentAsync(id, documents);
log.info("文档重新处理已提交: id={}, title={}", doc.getId(), doc.getTitle());
return doc;
}
/** 重新处理的数据源解析结果 */
private record SourceLoad(List<Document> documents, boolean reparsedFromFile) {}
/**
* 解析重新处理所需的数据源。
* <ol>
* <li><b>库内全文优先</b>:新文档 content 存的是全文(含 extraConfig.contentComplete 标记),
* 用它可保持与首次上传完全一致的抽取语义(例如 JSON 的 fields/pointer 模式),且不依赖文件是否还在。</li>
* <li><b>回退原始文件</b>:历史遗留文档的 content 是 2000 字截断预览,此时按 fileType 重新解析原始文件。</li>
* <li><b>明确拒绝</b>:两者都不可用时抛错,绝不静默用截断预览重建(reprocess 会先删旧向量,属不可逆丢失)。</li>
* </ol>
*/
private SourceLoad loadSourceDocuments(KnowledgeDocument doc) {
String content = doc.getContent();
boolean contentComplete = doc.getExtraConfig() != null
&& Boolean.TRUE.equals(doc.getExtraConfig().get("contentComplete"));
// 1) 库内全文优先
if (contentComplete && StringUtils.hasText(content)) {
return new SourceLoad(simpleStringDocumentReader.read(content), false);
}
// 2) 历史遗留文档:从原始文件按 fileType 重解析
if (StringUtils.hasText(doc.getFilePath())) {
java.io.File file = fileStorageConfig.getFilePath(doc.getFilePath()).toFile();
if (file.exists()) {
log.warn("库内 content 为历史截断数据,改为从原始文件重解析: id={}, fileType={}, path={}",
doc.getId(), doc.getFileType(), doc.getFilePath());
return new SourceLoad(parseFromFile(doc.getFileType(), file), true);
}
log.warn("原始文件不存在,无法完整重解析: id={}, path={}", doc.getId(), doc.getFilePath());
}
// 3) 无法完整还原
throw new RuntimeException("该文档为历史遗留数据(库内仅存截断预览,原始文件也已丢失),"
+ "无法完整重新处理,请重新上传该文档");
}
/**
* 按上传时的文件类型选择读取器,与各 uploadXxx 保持同一条解析路径。
* <p>
* 注意 fileType 是原始扩展名(pdf/docx/csv/html/…),故用 default 兜到 Tika,仅 md/json 单独分支。
* 已知降级:JSON 的三种解析模式(basic/fields/pointer)上传时未持久化,历史文档只能按 basic 还原
* (不丢数据,仅抽取口径可能变化),日志会 WARN 提示。
*/
private List<Document> parseFromFile(String fileType, java.io.File file) {
String type = fileType == null ? "" : fileType.toLowerCase();
return switch (type) {
case "md", "markdown" -> markdownDocumentLoader.loadMarkdownFromFile(file);
case "json" -> {
log.warn("历史 JSON 文档未持久化解析模式(basic/fields/pointer),按 basic 重解析: {}", file.getName());
yield jsonDocumentLoader.loadBasicJsonFromFile(file);
}
default -> new TikaDocumentReader(new FileSystemResource(file)).get();
};
}
/**
* 更新文档元信息
*/

100
src/main/java/com/wok/supportbot/service/IntentRouter.java

@ -1,7 +1,5 @@
package com.wok.supportbot.service;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.wok.supportbot.config.ChatModelFactory;
import lombok.AllArgsConstructor;
import lombok.Data;
@ -9,6 +7,7 @@ import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@ -18,6 +17,10 @@ import org.springframework.stereotype.Service;
* - FAQ: 常见问题 → FaqMatchEngine 精准匹配
* - RAG: 知识库检索 → 现有 RAG 流程
* - CHITCHAT: 闲聊 → 简单对话
*
* <p>结构化输出使用 Spring AI 标准组件 {@link BeanOutputConverter}:
* 由它把 JSON Schema 指令追加进 Prompt,并把模型返回的 JSON 反序列化为 {@link IntentResult},
* 不再手写正则解析。{@code ChatClient.entity(...)} 内部即是同一套机制。
*/
@Service
@Slf4j
@ -26,15 +29,23 @@ public class IntentRouter {
@Autowired
private ChatModelFactory chatModelFactory;
/** 意图分类 Prompt 模板 */
/** 结构化输出转换器(无状态,可安全复用):生成 Schema 指令 + 反序列化模型响应 */
private static final BeanOutputConverter<IntentResult> INTENT_CONVERTER =
new BeanOutputConverter<>(IntentResult.class);
/**
* 意图分类 Prompt 模板。
* 输出格式约束(JSON Schema)由 {@code BeanOutputConverter.getFormat()} 统一追加,模板中不再硬编码。
*/
private static final String INTENT_PROMPT_TEMPLATE = """
你是一个意图分类器。根据用户问题,判断其属于以下哪个意图:
- FAQ: 常见问题,如产品功能、价格、退换货政策、服务流程等标准问答
- RAG: 需要查阅文档/知识库才能回答的专业问题或细节问题
- CHITCHAT: 闲聊、问候、感谢、告别等非业务话题
仅返回JSON格式: {"intent":"FAQ|RAG|CHITCHAT","confidence":0.0-1.0}
用户问题: %s
%s
""";
// ==================== 意图结果内部类 ====================
@ -67,82 +78,37 @@ public class IntentRouter {
try {
ChatModel chatModel = chatModelFactory.getChatModel("CHAT");
String promptText = INTENT_PROMPT_TEMPLATE.formatted(userQuestion);
Prompt prompt = new Prompt(promptText);
String promptText = INTENT_PROMPT_TEMPLATE.formatted(userQuestion, INTENT_CONVERTER.getFormat());
String response = chatModel.call(prompt).getResult().getOutput().getText();
String response = chatModel.call(new Prompt(promptText)).getResult().getOutput().getText();
log.debug("意图分类原始响应: {}", response);
return parseIntentResponse(response);
} catch (Exception e) {
log.error("意图分类失败,降级为 RAG: question={}", userQuestion, e);
return new IntentResult("RAG", 0.0);
}
}
// ==================== 解析方法 ====================
/** 从 LLM 原始响应中提取 intent 字段值 */
private static final Pattern INTENT_PATTERN = Pattern.compile("\"intent\"\\s*:\\s*\"(FAQ|RAG|CHITCHAT)\"");
/** 从 LLM 原始响应中提取 confidence 字段值 */
private static final Pattern CONFIDENCE_PATTERN = Pattern.compile("\"confidence\"\\s*:\\s*(0?\\.\\d+|1\\.0|[01])");
/**
* 解析 LLM 返回的意图分类结果。
* 不依赖 JSON 结构完整性,使用正则直接从原始文本提取 intent 和 confidence 字段值,
* 可正确处理 LLM 输出的各种畸变:重复拼接、缺少逗号、额外文本、BOM/零宽字符等。
* 如果解析失败,默认返回 RAG(降级到现有流程)
*/
private IntentResult parseIntentResponse(String response) {
try {
// 1. 预处理:去除 BOM、零宽字符等不可见干扰字符
String cleaned = response.trim()
.replace("", "")
.replace("​", "")
.replace("‌", "")
.replace("‍", "")
.replace(" ", " ")
.replace("‎", "")
.replace("‏", "")
.replace("⁠", "");
// 2. 去除 markdown 代码块包裹
if (cleaned.startsWith("```")) {
cleaned = cleaned.replaceAll("^```(?:json)?\\s*", "").replaceAll("\\s*```$", "");
}
// 3. 正则提取 intent(取第一个匹配,应对 LLM 重复拼接 JSON 的情况)
Matcher intentMatcher = INTENT_PATTERN.matcher(cleaned);
String intent = intentMatcher.find() ? intentMatcher.group(1) : null;
// 4. 正则提取 confidence(取第一个匹配)
Matcher confidenceMatcher = CONFIDENCE_PATTERN.matcher(cleaned);
double confidence = confidenceMatcher.find() ? Double.parseDouble(confidenceMatcher.group(1)) : 0.5;
if (intent == null) {
log.warn("未能从 LLM 响应中提取到 intent 字段,降级为 RAG");
return new IntentResult("RAG", 0.0);
}
if (!isValidIntent(intent)) {
log.warn("无效的意图类型: {}, 降级为 RAG", intent);
IntentResult result = INTENT_CONVERTER.convert(response);
if (result == null || !isValidIntent(result.getIntent())) {
log.warn("意图分类结果无效,降级为 RAG: rawResponse={}", abbreviate(response));
return new IntentResult("RAG", 0.5);
}
return new IntentResult(intent, confidence);
return result;
} catch (Exception e) {
String truncated = response != null && response.length() > 200
? response.substring(0, 200) + "..."
: response;
log.warn("意图分类解析异常,降级为 RAG: rawResponse={}", truncated, e);
log.warn("意图分类失败,降级为 RAG: question={}", abbreviate(userQuestion), e);
return new IntentResult("RAG", 0.0);
}
}
/**
* 校验意图类型是否有效
* 校验意图类型是否有效(模型可能返回枚举外的值)
*/
private boolean isValidIntent(String intent) {
return "FAQ".equals(intent) || "RAG".equals(intent) || "CHITCHAT".equals(intent);
}
/**
* 日志截断,避免回显整段响应
*/
private static String abbreviate(String text) {
if (text == null) {
return null;
}
return text.length() > 200 ? text.substring(0, 200) + "..." : text;
}
}

15
src/main/resources/application.yml

@ -131,10 +131,13 @@ logging:
jwt:
# 签名密钥(生产环境务必通过环境变量 JWT_SECRET 覆盖)
secret: ${JWT_SECRET:support-bot-jwt-secret-key-2026-please-change-in-production}
# 访问令牌有效期:15分钟(毫秒),降低 XSS 窃取后的利用窗口
expiration: 900000
# 刷新令牌有效期:7天(毫秒)
refresh-expiration: 604800000
# 访问令牌有效期:15分钟,降低 XSS 窃取后的利用窗口
# 单位写法:支持 15m / 900s / 900000ms 等可读格式;纯数字按毫秒解析(向后兼容旧配置)
expiration: 15m
# 刷新令牌有效期:8小时。关闭页面超过该时长后重新打开需重新登录。
# 这是会话时长的唯一配置项,所有环境统一生效;refresh Cookie 的 Max-Age 由 AuthController 读取本项自动跟随,不得硬编码。
# 注意:保留滚动续期 —— 页面持续活跃的用户每 15 分钟会静默刷新一次,窗口随之重置,不会掉线。
refresh-expiration: 8h
# refresh token cookie 是否标记 Secure(作为总开关;实际 Secure 属性由 AuthController 按请求是否 HTTPS 动态决定,HTTP 自动降级为 false)
refresh-cookie-secure: true
# 使用默认密钥时是否直接启动失败(生产建议 true)
@ -144,8 +147,8 @@ jwt:
sdk-secret: ${JWT_SDK_SECRET:support-bot-sdk-jwt-secret-2026-please-change}
# 使用默认 SDK 密钥时是否直接启动失败(生产建议 true)
fail-if-default-sdk-secret: false
# SDK Token 默认有效期:2小时(毫秒)
sdk-expiration: 7200000
# SDK Token 默认有效期:2小时,作为 SDK 换 Token 接口未指定 ttl 时的默认值
sdk-expiration: 2h
# ==================== 登录安全配置 ====================
security:

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

@ -154,7 +154,7 @@ COMMENT ON COLUMN knowledge_document.source_name IS '原始文件名';
COMMENT ON COLUMN knowledge_document.file_type IS '文件类型';
COMMENT ON COLUMN knowledge_document.file_size IS '文件大小(字节)';
COMMENT ON COLUMN knowledge_document.file_path IS '原始文件存储路径(相对路径)';
COMMENT ON COLUMN knowledge_document.content IS '原文内容(截断预览)';
COMMENT ON COLUMN knowledge_document.content IS '原文全文(用于重新处理时重建分块;接口仅返回 2000 字预览)';
COMMENT ON COLUMN knowledge_document.category_id IS '所属分类ID';
COMMENT ON COLUMN knowledge_document.folder_id IS '所属目录ID(0 表示未指定目录,直接挂分类根)';
COMMENT ON COLUMN knowledge_document.tags IS '标签(JSON)';

2
src/main/resources/knowledge-base.sql

@ -64,7 +64,7 @@ COMMENT ON COLUMN knowledge_document.title IS '文档标题';
COMMENT ON COLUMN knowledge_document.source_name IS '原始文件名';
COMMENT ON COLUMN knowledge_document.file_type IS '文件类型 - pdf/md/json/txt/word/excel 等';
COMMENT ON COLUMN knowledge_document.file_size IS '文件大小(字节)';
COMMENT ON COLUMN knowledge_document.content IS '原文内容(截断预览)';
COMMENT ON COLUMN knowledge_document.content IS '原文全文(用于重新处理时重建分块;接口仅返回 2000 字预览)';
COMMENT ON COLUMN knowledge_document.category_id IS '所属分类ID - 0表示未分类';
COMMENT ON COLUMN knowledge_document.tags IS '标签列表(JSON数组)';
COMMENT ON COLUMN knowledge_document.chunk_count IS '分块数量';

Loading…
Cancel
Save