# 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 知识库检索、多轮对话、结构化数据提取、知识库全生命周期管理。 ## 构建与运行 ```bash # 编译 ./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` 形态;废弃的 `Flux` 和 `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` 全库无检索消费点)。 ## 关键配置 - `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 字段时务必加上此注解 - 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 地址后,点击「获取模型」即可自动填充模型名称下拉列表(`` 支持搜索选择 + 自定义输入) ### 依赖版本 - 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`,启动时检查 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/multimodal` | VolcengineMultimodalEmbeddingModel | | volcengine (text) | `/embeddings` | OpenAiEmbeddingModel | | moonshot | `/embeddings` | OpenAiEmbeddingModel | | zhipu | `/embeddings` | OpenAiEmbeddingModel | | deepseek | `/v1/embeddings` | OpenAiEmbeddingModel | | openai | `/v1/embeddings` | OpenAiEmbeddingModel | - 注意:各提供商的 embedding 端点兼容性由用户自行验证,向量维度需与 PgVectorStore 的 `dimensions` 一致 - **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` 自动按需引入(模板直接写 ``) - **状态管理**: 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 文本块有两个隐蔽行为: 1. **闭合定界符前的尾随空格被剥离**:`ORDER BY """` 中 `BY` 后的空格会丢,`"ORDER BY "` 变成 `"ORDER BY"`。 2. **开启定界符后的前导换行被消费**:`+ """` 后紧跟的下一文本块,其第一行前导换行不算内容,导致前一段末尾与后一段开头直接相连(如 `DESC` + `LIMIT` 变成 `DESCLIMIT`)。 **做法**: 涉及排序子句、动态变量拼接时,用普通字符串显式带空格拼接,不要依赖文本块边界: ```java // ✅ 正确:显式空格 + 变量,不碰文本块边界 """ ... ) t """ + " ORDER BY " + orderBy + " LIMIT ? OFFSET ?"; // ❌ 错误:ORDER BY 尾随空格被剥离、orderBy 与 LIMIT 粘连 """ ... ) t ORDER BY """ + orderBy + """ LIMIT ? OFFSET ? """; ``` `""" + whereClause` 这类「闭合定界符独立一行 + whereClause 自带前导空格」的写法是安全的,因为不依赖尾随空格。 **反面案例**: `MessageFeedbackService.listConversationsByFeedback` 用 `ORDER BY """ + orderBy + """` 拼接,编译后 `ORDER BY ` 尾随空格被剥离、`DESC` 与 `LIMIT` 粘连成 `ORDER BYfeedback_time DESCLIMIT`,PostgreSQL 报 `syntax error`(Spring 翻译为 `BadSqlGrammarException`),前端显示「查询失败:bad SQL grammar」。 ### 前端:弹窗实现统一模式 项目中存在两种弹窗模式,**不可混用**: | 模式 | 实现方式 | 适用 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` 块必须透传服务器错误信息,禁止吞掉错误只显示泛化提示: ```javascript // ✅ 正确 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) 同源静态资源 `