38 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
项目概述
AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支持 RAG 知识库检索、多轮对话、结构化数据提取、知识库全生命周期管理。
构建与运行
# 编译
./mvnw compile
# 运行(端口 9090)
./mvnw spring-boot:run
# 运行测试
./mvnw test
# 运行单个测试类
./mvnw test -Dtest=SupportBotApplicationTests
# 运行单个测试方法
./mvnw test -Dtest=SupportBotApplicationTests#testRag
前提条件: PostgreSQL 12+ 需运行且安装 PGVector 扩展,数据库 support_bot 需存在。knowledge_category、knowledge_document、ai_model_config 等表由 DatabaseInitConfig 自动创建,无需手动建表。
测试说明: 所有测试均为集成测试(@SpringBootTest),需要运行中的 PostgreSQL 和有效的 DashScope API Key。测试类:SupportBotApplicationTests(对话/RAG)、PgVectorVectorStoreConfigTest(向量存储)、QueryTransformerTests(查询重写策略)。无单元测试。
访问地址: 前端管理页面 http://localhost:9090/index.html,API 文档 http://localhost:9090/doc.html(Knife4j)
核心架构决策
手动配置 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 活跃配置动态创建 - ChatClient 构建: 所有权在
AssistantApp(getChatClient),按appType+allowedMcpTools缓存不同实例 - Advisor 链:
ContentSafetyAdvisor(最外层,HIGHEST_PRECEDENCE)→MessageChatMemoryAdvisor(记忆)→MyLoggerAdvisor(日志) - SSE 流式: 仅保留
Flux<String>形态;废弃的Flux<ServerSentEvent>和SseEmitter已移除
ChatMemory 持久化
当前使用 DatabaseChatMemory(PostgreSQL 持久化),无文件型 ChatMemory(早期的 FileBasedChatMemory 已删除,Kryo 依赖一并移除)。
统一对话管道(重构后)
对话管道由 ChatPipeline(编排层)+ RagPipeline(RAG 检索层)+ AssistantApp(执行层)组成:
用户请求
→ 鉴权/角色解析(Controller)
→ ChatPipeline.buildRequest(ChatContext)
→ IntentRouter 意图路由(CHITCHAT/FAQ/RAG)
→ RagPipeline.retrieve(FAQ 优先 → 查询重写 → 统一检索)
→ 组装 finalMessage + finalSystemPrompt + 资料块
→ AssistantApp.chat / chatStream(构建 ChatClientRequestSpec → call/stream)
- ChatPipeline: 纯编排,不持有 ChatClient;产出
ChatRequest决策对象 - RagPipeline: 统一 RAG 检索,所有策略(含 MULTI_QUERY)均走"手动检索 + 资料块注入 system prompt"模式,不再使用
RetrievalAugmentationAdvisor的 query augmenter - RAG 查询重写策略: 由
RagPipeline统一路由,AssistantApp等旧方法已移除 - IntentRouter: 已在 ChatPipeline 接入,
AiController.shouldBypassKnowledgeRetrieval已移除 - 分类过滤: 统一由
CategoryFilter工具类处理(parse/normalize/buildExpression) - AssistantApp 入口:
chat(ChatContext)/chatStream(ChatContext)/retrieveSources(ChatContext),旧方法(doChat*、doChatWithRag*)已移除 - Open API:
OpenApiController已接入ChatPipeline,补齐角色/RAG/FAQ/MCP/分类隔离能力
文档处理管道
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)。新文档走库内全文分支,语义不变。
关键配置
application.yml含 DashScope API Key,已被.gitignore排除- 模型名称、温度、最大 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)。
- 语义为滚动续期:access token 15 分钟过期后前端静默调
- 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为准
模型配置管理
- ai_model_config 表: 存储大模型配置,支持多套配置按 App 类型(CHAT / EMBEDDING / RAG_REWRITE)独立管理。所有模型参数(名称、温度、最大Token、API Key、Base URL 等)全部由此表管理,不再依赖 application.yml
- 激活互斥: 同一 App 类型只能有一个
is_active=true的配置,激活操作由 Service 层@Transactional保证 - 启动 seed: 首次启动时使用硬编码默认值写入 DB(
qwen-turbo/text-embedding-v2),用户可在前端修改。DashScope 自动配置已禁用(spring.ai.dashscope.enabled=false),api-key 可选——yml 与 DB 均未配置 api-key 不影响启动,实际调用 AI 功能前在前端「AI 大模型配置管理」页面配置即可 - 启动校验:
ModelConfigLoader在应用就绪后检查 DB 中每种 App 类型是否有活跃配置,并对 DashScope 提供商比较 DB 与 yml 的 API Key 一致性 - API Key 脱敏: 前端展示时只显示前 4 位 +
****+ 后 4 位 - 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();MCP Server 增删改/启停/全量刷新时McpServerConfigController亦会调用AssistantApp.clearCache()(避免继续使用旧的 MCP 工具集) - 动态模型列表获取:
ModelListFetcher通过调用各提供商的/v1/models兼容端点(DashScope 用/compatible-mode/v1/models),动态获取可用模型列表。前端填入 API Key + API 地址后,点击「获取模型」即可自动填充模型名称下拉列表(<datalist>支持搜索选择 + 自定义输入)
依赖版本
- 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 BOMspring-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.xToolCallingAutoConfiguration的 ClassNotFound workaround,1.1.2 已无该耦合)
EmbeddingModel 架构
- EmbeddingConfigFixer:
ApplicationListener<ApplicationReadyEvent>,启动时检查 EMBEDDING 配置合理性、校验 EmbeddingModel 实际维度与配置维度是否一致,不一致时 WARN 告警并给出修复步骤。不再强制修正非 DashScope 配置,尊重用户在 DB 中配置的提供商和模型 - EmbeddingModelFactory:按 DB 活跃配置动态创建/缓存 EmbeddingModel,支持多种提供商:
- DashScope(通义千问):
DashScopeEmbeddingModel - OpenAI 兼容提供商(DeepSeek / Kimi / 智谱 / OpenAI):通过
OpenAiEmbeddingModel+ 自定义 baseUrl +embeddingsPath创建 - 豆包文本模型(volcengine + 非 vision):通过
OpenAiEmbeddingModel,embeddingsPath=/embeddings - 豆包多模态模型(volcengine +
*vision*):通过VolcengineMultimodalEmbeddingModel,手写RestClient直调/embeddings/multimodal,适配{type, text}格式 - 各厂商 embeddingsPath 映射:
提供商 embeddingsPath 实现类 dashscope — DashScopeEmbeddingModel volcengine (vision) /embeddings/multimodalVolcengineMultimodalEmbeddingModel volcengine (text) /embeddingsOpenAiEmbeddingModel moonshot /embeddingsOpenAiEmbeddingModel zhipu /embeddingsOpenAiEmbeddingModel deepseek /v1/embeddingsOpenAiEmbeddingModel openai /v1/embeddingsOpenAiEmbeddingModel - 注意:各提供商的 embedding 端点兼容性由用户自行验证,向量维度需与 PgVectorStore 的
dimensions一致
- DashScope(通义千问):
- DynamicEmbeddingModel:代理类实现
EmbeddingModel接口,每次调用委托给 Factory,使 VectorStore 无需重建即可热切换 - 前端:
ModelConfigManager.js对 EMBEDDING 类型不再限制 provider,可自由选择任意提供商;EMBEDDING 类型弹窗增加「向量维度」输入框(写入extraConfig.dimensions)
前端架构
- 技术栈: Vue 3.5 + TypeScript + Vite + TDesign Vue Next(
tdesign-vue-next)+ Pinia + Vue Router(hash 模式) - 源码目录:
frontend/(不再手写static/js;static现在是npm run build的产物,由frontend/cp-to-static.mjs拷贝进去) - 入口:
frontend/src/main.ts→App.vue(登录页 /MainLayout切换)→router/index.ts - 组件化: 每个功能页面一个
.vue文件(frontend/src/views/),TDesign 组件通过unplugin-vue-components自动按需引入(模板直接写<t-xxx>) - 状态管理: Pinia(
frontend/src/stores/),路由守卫在router/index.ts中做页面数据预加载 - API 封装:
frontend/src/api/*.ts统一封装(axios 实例 + 拦截器见request.ts),API 基址为空字符串(同源部署) - SSE 流式:
frontend/src/utils/sse.ts中readSSEStream*()统一处理 SSE 接口 - 开发运行:
cd frontend && npm run dev(Vite 代理到 9090);生产npm run build后拷贝进static - UI 开发准则: 页面 UI 开发统一遵循
frontend/UI-DEV-GUIDE.md(组件优先、复用 composables、Token 配色等铁律)
开发规范与踩坑记录
后端:数据库变更必须同步到启动初始化
规则: 任何涉及表结构(建表、增删改列、索引)、初始数据(种子数据、默认配置)的变更,必须同步记录到以下两个文件,否则新部署环境或重建数据库时会丢失变更:
| 文件 | 作用 | 需要同步的内容 |
|---|---|---|
DatabaseInitConfig.java |
应用启动自动执行(主力) | 建表 SQL(CREATE TABLE IF NOT EXISTS)、列迁移(ALTER TABLE ADD COLUMN IF NOT EXISTS)、种子数据(ON CONFLICT ... DO UPDATE) |
init-database.sql |
手动备用脚本 | 与 DatabaseInitConfig 保持一致的完整建表 + 索引 + 种子数据 |
流程: 改完 Entity / Mapper 后,先在 DatabaseInitConfig.init() 中添加对应的初始化逻辑(幂等检查 + safeInit 包裹),再同步更新 init-database.sql。
反面案例: knowledge_document.content_hash 列仅在 DatabaseInitConfig 中迁移,未同步到 knowledge-base.sql,导致该脚本变为过时版本。
后端:vector_store 物理删除(禁止在原生 SQL 中引用 is_delete)
规则: vector_store 表由 Spring AI PgVectorStore 以 initializeSchema(true) 自动建表,默认只有 id / content / metadata(json) / embedding 四列;DatabaseInitConfig 仅额外补了 content_tsvector、create_time 两列。因此 vector_store 只保证有六列:id / content / metadata / embedding / content_tsvector / create_time,没有 is_delete、没有 update_time。
- vector_store 的向量删除走物理删除(
pgVectorVectorStore.delete(ids)),不存在逻辑删除语义,原生 SQL 中禁止写AND is_delete = false。 - 凡是引用业务表(
knowledge_document等 MyBatis Plus 逻辑删除表)的 SQL 里常用的is_delete = false过滤,不得照搬到 vector_store。 - 对 vector_store 写原生 SQL 前,先确认引用的列在上述六列白名单内。
反面案例: HybridSearchService.keywordSearch() 从 knowledge_document 表查询照搬了 AND is_delete = false,但 vector_store 无此列,PostgreSQL 报 column "is_delete" does not exist(SQLState 42703),被 Spring 统一翻译为 BadSqlGrammarException,前端显示「搜索失败: bad SQL grammar」,且仅关键词/混合模式触发(向量模式走 Spring AI similaritySearch 不执行该 SQL)。
后端:数据库初始化一律幂等自愈
规则: 新增列/索引/触发器的 init 方法(DatabaseInitConfig 内)不要「列已存在就 return」,应使用幂等 SQL 组合自愈:ADD COLUMN IF NOT EXISTS + 回填(UPDATE ... WHERE xxx IS NULL)+ CREATE INDEX IF NOT EXISTS + CREATE OR REPLACE FUNCTION + DROP TRIGGER IF EXISTS + CREATE TRIGGER。避免半途失败或对象被手动删除后留下静默缺口(如新文档 content_tsvector 恒为 NULL、检索静默返回空)。
后端:Long ID 序列化为字符串
规则: 所有雪花算法生成的 Long 类型 ID 字段,必须保证前端收到的是字符串而非数字,防止 JS 超过 Number.MAX_SAFE_INTEGER(2^53)精度丢失。
| 场景 | 做法 |
|---|---|
| Entity 字段 | @JsonSerialize(using = ToStringSerializer.class) + @TableId(type = IdType.ASSIGN_ID) |
| 原生 JDBC SQL | SELECT u.id::TEXT AS id(CAST 为 TEXT),不能直接 SELECT u.id |
| Controller Map 返回 | Map.of("id", user.getId().toString()) |
反面案例: 用户管理 listUsers 使用原生 SQL 未 CAST,导致新建的用户(雪花 ID 约 19 位)在前端编辑时 ID 被截断,报"用户不存在"。
后端:敏感字段脱敏
规则: 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 文本块有两个隐蔽行为:
- 闭合定界符前的尾随空格被剥离:
ORDER BY """中BY后的空格会丢,"ORDER BY "变成"ORDER BY"。 - 开启定界符后的前导换行被消费:
+ """后紧跟的下一文本块,其第一行前导换行不算内容,导致前一段末尾与后一段开头直接相连(如DESC+LIMIT变成DESCLIMIT)。
做法: 涉及排序子句、动态变量拼接时,用普通字符串显式带空格拼接,不要依赖文本块边界:
// ✅ 正确:显式空格 + 变量,不碰文本块边界
""" ... ) 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」。
前端:弹窗实现统一模式
项目中存在两种弹窗模式,不可混用:
| 模式 | 实现方式 | 适用 CSS 类 |
|---|---|---|
| A: CSS 类切换 | :class="{ active: xxx.visible }" |
.modal-overlay(CSS 定义 display:none + .active { display:flex }) |
| B: 内联样式 + v-if | v-if="xxx" style="...display:flex..." |
无 .modal-overlay,用内联样式 |
禁止: 将 .modal-overlay 与 v-if 搭配使用 — v-if 控制 DOM 存在性,但不添加 .active 类,导致弹窗渲染后被 CSS display:none 隐藏,按钮点击无反应。
前端:错误处理显示服务器信息
规则: catch 块必须透传服务器错误信息,禁止吞掉错误只显示泛化提示:
// ✅ 正确
catch (e) { toast(e.message || '操作失败', 'error') }
// ❌ 错误 — 用户和开发者都无法排查
catch (e) { toast('操作失败', 'error') }
前后端:AI 执行链示意图与代码保持同步
规则: 对 app/、rag/、advisor/ 包中核心编排类的结构性变更(新增/删除管道阶段、调整调用链、引入新组件),必须同步更新以下两处架构图,否则示意图与实际代码会不一致:
| 文件 | 内容 | 说明 |
|---|---|---|
frontend/src/views/PipelineFlow.vue |
Mermaid 流程图 DSL(GRAPH_DEFINITION 常量) |
管理后台「AI 执行链」页面的可视化图表 |
CLAUDE.md 中的 ASCII 管道图 |
文本流程描述("统一对话管道"章节) | 供 AI 和开发者快速了解架构 |
触发条件(满足任一即需更新):
ChatPipeline.buildRequest()的决策分支(意图路由、FAQ、RAG、纯对话路径)发生变更RagPipeline.retrieve()的检索流程(查询重写策略、检索方式、资料拼装)发生变更AssistantApp的 Advisor 链成员或顺序发生变更(如新增/移除 Advisor)- 新增管道阶段组件(如
IntentRouter、SuggestionGenerator、SimpleCircuitBreaker等)或移除现有组件 - 组件间调用关系调整(如原来 A→B 改为 A→C→B)
图表元数据: PipelineFlow.vue 中 DSL 首行有 %%graph-meta 注释标记最后更新时间,修改图表时必须更新该日期。
反面案例: 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) - 模型配置:
/model-config/*(AiModelConfigController) - 文档上传:
/upload/*(DocumentController) - 文档管理:
/document/*(DocumentController) - 批量操作:
/document/batch/*(DocumentController,用 POST 避免 DELETE+RequestBody 路径冲突) - 分类管理:
/category/*(DocumentController) - 消息反馈:
/feedback/*(MessageFeedbackController) - 敏感词管理:
/sensitive-word/*(SensitiveWordController) - FAQ 管理:
/faq/*(FaqController) - SDK 认证:
/open-api/auth/*(AuthController,Token 换取) - API Key 角色绑定:
/api-key/{id}/roles(ApiKeyController,admin 角色) - LLM 调用追踪:
/llm-trace/*(LlmCallTraceController,admin 角色)
Filter 优先级
| 优先级 | Filter | 路径 | 说明 |
|---|---|---|---|
HIGHEST + 1 |
SdkAuthFilter |
/ai/** |
SDK JWT 鉴权 |
HIGHEST + 2 |
ApiKeyAuthFilter |
/open-api/** |
API Key 鉴权 |
| SecurityFilterChain 内 | JwtAuthFilter |
管理接口 | 管理后台 JWT |
SDK 鉴权架构
采用两段式鉴权:客户端后端用 API Key 换取短期 JWT Token → SDK 携带 Token 请求 /ai/** → SdkAuthFilter 校验放行。SDK JWT 密钥独立于管理后台 JWT(jwt.sdk-secret)。
- API Key 支持绑定客服角色列表(
role_idsJSONB 字段),Token 换取时优先返回绑定的角色 - 未绑定角色的 API Key 返回所有启用角色(向后兼容)
- JWT Token 中
sub为 apiKeyId,rids为允许的角色 ID 列表 - 第三方系统接入指南详见
SDK-INTEGRATION.md
P0 阶段新增功能
内容安全过滤(P0-004)
- DFA 引擎:
ContentSafetyService使用字典树匹配敏感词,volatile+ copy-on-write 保证线程安全热加载 - ContentSafetyAdvisor: 实现
BaseAdvisor,getOrder()返回HIGHEST_PRECEDENCE(Advisor 链最外层),before 阶段检查用户输入、after 阶段检查 AI 输出 - 敏感词级别: level=1 仅脱敏(MASK),level=2 拦截(BLOCK)返回安全提示
- 审计日志:
content_audit_log表记录所有违规事件,不删除 - 前端:
SensitiveWordManager.js在系统设置 Tab,支持 CRUD + 批量导入 + 审计日志查看
用户反馈系统(P0-002)
- 反馈实体:
MessageFeedback,按message_id唯一索引,重复提交覆盖(upsert 语义) - 反馈类型: THUMBS_UP(有帮助)/ THUMBS_DOWN(没帮助),点踩可选原因分类 + 自由文本
- ChatPanel: 每条 AI 回复下方有 👍/👎 按钮,点击调用
POST /feedback - Chat SDK:
handleFeedback()已连接后端 API,同时保留 localStorage 作为乐观 UI 缓存 - 会话导出:
ConversationService.exportConversation()导出的 TXT 中包含反馈信息
意图识别 + FAQ 精准匹配(P0-003)
- 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)
- 前端:
FaqManager.js在知识库文档管理 Tab,支持 CRUD + 批量 JSON 导入/导出 + 启用/禁用
混合检索 + 重排序引擎(P0-001)
- 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 类型配置;HTTP 由RestClient+JdkClientHttpRequestFactory显式设置 connect/read 超时(各 3s,原RestTemplate无任何超时),超时/异常自动 fallback 到 RRF 原始排序 - vector_store 全文检索: 新增
content_tsvector列 + GIN 索引 + PostgreSQL 触发器自动维护 - 前端:
DocSearch.js增加检索模式下拉选择(向量/关键词/混合),结果标注来源模式
LLM 调用追踪(提示词调试)
- llm_call_trace 表:append-only,无逻辑删除。记录每次 LLM 调用的完整现场——最终
system_prompt+global_prompt/role_prompt/rag_context分段快照、用户消息、AI 回复(截断)、模型参数(model_name/provider/temperature/max_tokens)、耗时、意图(CHAT/CHITCHAT/FAQ/RAG)、状态(COMPLETE/ERROR/CANCEL/FAQ/BYPASS)。 - 埋点位置:
AssistantApp.chatWithEvents/chatStream顶部埋点,覆盖 FAQ 命中、熔断降级、流式断连/异常等路径;流式用doOnNext聚合分片 +doFinally按终止信号落库(绝不在方法返回处计时)。 - 异步写入:
LlmCallTraceService.recordAsync走@Async("traceExecutor")(有界线程池见AsyncExecutorConfig,替代默认SimpleAsyncTaskExecutor线程爆炸隐患;RagHitLogService已一并切换)。 - 查询面板:前端「系统设置 → 提示词追踪」(
PromptTracePanel.vue,admin),支持筛选/搜索/详情(来源标注)/聚合统计/并排对比/跳转编辑(deep-link?roleId=、?key=ai_system_prompt)。 - 安全声明:
system_prompt/user_message/ai_response属敏感数据(提示词为商业秘密),当前明文存储、仅 admin 可查,落库前做敏感词脱敏(ContentSafetyService.mask)+ AI 回复截断;保留期限由system_config.llm_trace_retention_days控制(默认 30 天,@Scheduled每日 2 点自动清理)。字段级加密留二期。 - 预留:
account_id/api_key_id列预留租户隔离与个人信息删除权,本期不写入;旁路 LLM 调用(意图识别/查询重写/建议生成)追踪留二期(表加trace_type/source字段)。
已知 TODO
DocumentService.updateDocumentMetadata(): Spring AI 无直接更新 vector_store metadata 的 API,向量元数据同步留后续DocumentService.searchDocuments(): 分类过滤已实现(FilterExpressionBuilder组合enabled+categoryId过滤表达式,向量检索异常时回退到本地 metadata 过滤)—— 原「Spring AI filter 支持有限」的 TODO 已不成立CompressionQueryRewriter: 当前传入空历史列表- MyBatis Plus
mybatis-plus-spring-boot3-starter不含PaginationInnerInterceptor,分页通过 SQLLIMIT/OFFSET手动实现 PgVectorStoreConfig.dimensions(1024)硬编码了向量维度,切换非 1024 维的 Embedding 模型时需修改并重建 vector_store 表 → 已修复:维度由knowledge.vector.dimension配置,启动时自动检测不匹配并告警