diff --git a/CLAUDE.md b/CLAUDE.md index 68ad9c4..dd85b7b 100644 --- a/CLAUDE.md +++ b/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` 形态;废弃的 `Flux` 和 `SseEmitter` 已移除 ### ChatMemory 持久化 -当前使用 `DatabaseChatMemory`(PostgreSQL 持久化),`FileBasedChatMemory`(Kryo 序列化)已注释掉。 +当前使用 `DatabaseChatMemory`(PostgreSQL 持久化),无文件型 ChatMemory(早期的 `FileBasedChatMemory` 已删除,Kryo 依赖一并移除)。 ### 统一对话管道(重构后) 对话管道由 `ChatPipeline`(编排层)+ `RagPipeline`(RAG 检索层)+ `AssistantApp`(执行层)组成: @@ -67,7 +67,21 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支 - **Open API**: `OpenApiController` 已接入 `ChatPipeline`,补齐角色/RAG/FAQ/MCP/分类隔离能力 ### 文档处理管道 -`DocumentService.uploadDocument()` 统一流程:文档提取 → `MyTokenTextSplitter` 分块 → `MyKeywordEnricher` AI 关键词提取 → `pgVectorVectorStore.add()` 向量化存储。每个分块的 metadata 中注入 `documentId`、`chunkIndex`、`sourceName`、`title` 以关联 `knowledge_document` 表。 +`DocumentService.uploadDocument()` 统一流程:文档提取(官方 `org.springframework.ai.reader.tika.TikaDocumentReader` / `MarkdownDocumentReader` / `JsonReader`)→ `OverlapTokenTextSplitter` 分块 → 为每块写 metadata → 按批向量化(默认 50 块/批,配置项 `knowledge.vector.batch-size`)`pgVectorVectorStore.add(batch)` 入库。每个分块的 metadata 注入 `documentId`、`chunkIndex`、`sourceName`、`title`、`categoryId`、`enabled` 关联 `knowledge_document` 表。 + +**向量化加固**(`DocumentProcessingService`):逐批 try-catch 隔离,失败批只记录缺失区间后继续,已入库块保留;`chunk_count` 记实际入库块数,`error_message` 聚合"已入库 x/y 块 + 缺失区间 + 原因";文档级失败自动整体重试至多 2 次(仅瞬时/限流/超时类错误,4xx 不空转),重试前先清残留向量再重建。**无逐块 AI 关键词提取环节**(`MyKeywordEnricher` 已移除,其产出 `excerpt_keywords` 全库无检索消费点)。 + +**`content` 列存全文 + 重新处理的数据源规则**(`DocumentService`,修复了「重跑丢内容」缺陷): + +| 项 | 规则 | +|---|---| +| 存储 | `knowledge_document.content` 存**原文全文**(不再截断),并写 `extra_config.contentComplete = true` 作为完整性标记 | +| 序列化 | 实体 `content` 标 `@JsonIgnore`,**任何接口都不返回全文**(否则列表接口每行都带整篇正文)。仅文档详情接口(`DocumentController.toDetailMap`)返回 2000 字预览(键名仍是 `content`)+ `contentTruncated` 布尔标志 | +| 重新处理的数据源 | ① 有 `contentComplete` 标记 → 用库内全文(语义最忠实,如 JSON 的 fields/pointer 模式)② 历史遗留文档(无标记,content 是 2000 字截断预览)→ 按 `fileType` 从原始文件重解析,成功后**回填全文并打标记**(此后不再依赖文件)③ 两者都不可用 → **明确报错拒绝** | + +**踩坑记录**:`reprocessDocument` 原先直接 `simpleStringDocumentReader.read(doc.getContent())`,而 `content` 是 2000 字截断预览,且 `DocumentProcessingService` 以 `cleanBeforeFirstAttempt=true` 运行(**先删光旧向量**)—— 对超过 2000 字符的文档重跑会永久丢失其余内容。修复前**不要**对存量文档执行 `POST /document/batch/reprocess`。判断遗留文档:`extra_config->>'contentComplete' IS NULL`;其中 `length(content) = 2000` 的才是真正被截断的。 + +**JSON 解析模式的已知降级**:JSON 的 basic/fields/pointer 三种模式上传时未持久化,历史 JSON 文档从文件重解析只能按 basic 还原(不丢数据,仅抽取口径可能变化,日志有 WARN)。新文档走库内全文分支,语义不变。 ## 关键配置 @@ -76,9 +90,18 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支 - **模型名称、温度、最大 Token 等参数已全部迁移到前端「AI 大模型配置管理」页面**,通过 `ai_model_config` 表管理,不再在 yml 中配置(yml 仅保留 `api-key`) - MyBatis Plus 逻辑删除字段: `isDelete`,主键策略: `assign_id`(雪花算法) - **雪花 ID 精度问题**: `KnowledgeDocument.id`、`categoryId` 和 `KnowledgeCategory.id`、`parentId` 已添加 `@JsonSerialize(using = ToStringSerializer.class)`,序列化为字符串避免前端 JS 精度丢失。新增 Long ID 字段时务必加上此注解 +- **JWT 时间配置统一用 Duration 可读格式**: `jwt.expiration` / `jwt.refresh-expiration` / `jwt.sdk-expiration` 均绑定为 `java.time.Duration`(`@Value` 直绑),值写成 `15m` / `8h` / `900s` 等可读形式,**纯数字仍按毫秒解析**(向后兼容旧配置)。项目同类先例:`storage.sftp.connect-timeout: 10s`(`StorageProperties`)。新增时间类配置项时照此办理,不要写裸毫秒。 +- **管理后台会话有效期**: refresh token 有效期由 `jwt.refresh-expiration` **唯一**驱动(`application.yml`,当前 `8h`)。该配置**全环境统一生效**,不在 `application-dev/prod.yml` 中覆盖;`JwtTokenProvider` 构造器上的 `:8h` 仅为代码级兜底。**refresh Cookie 的 Max-Age 必须经 `jwtTokenProvider.getRefreshExpirationSeconds()` 取值,禁止硬编码**(`AuthController` 登录与刷新两处),否则「改配置不改 Cookie」会造成有效期漂移。 + - 语义为**滚动续期**:access token 15 分钟过期后前端静默调 `/auth/refresh`,服务端重签并重置窗口。因此**页面持续活跃的用户不会掉线**,只有闲置超过该时长(含关闭页面超过该时长后重开,refresh Cookie 已过期)才需重新登录。 + - 相关前端链路:`App.vue onMounted` 启动自检(`/auth/me` → 失败则 `tryRefreshToken` → **再校验一次 `/auth/me`**,仍失败即登出)+ `api/request.ts` 的 401 自动刷新重试(single-flight + 重试上限 `_retry`)。 +- **SDK Token 有效期**: `jwt.sdk-expiration`(当前 `2h`)作为 SDK 换 Token 接口(`POST /open-api/auth/token`,SDK 版 `controller/AuthController`)**未指定 ttl 时的默认值**,由 `SdkJwtTokenProvider.getDefaultExpirationMillis()` 提供。有效期边界 `[5min, 24h]` 只在 `SdkJwtTokenProvider` 的 `MIN_EXPIRATION` / `MAX_EXPIRATION` 两处常量定义,控制器通过 `clampExpirationMillis()` 复用,**不得在控制器内重复写毫秒魔数**。注意 SDK 对外的 `ttl` 请求参数与 `expiresIn` 响应字段单位是**秒**(见 `SDK-INTEGRATION.md`),与内部毫秒配置是两套单位,勿混淆。 - PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象 `'{}'`,非数组 `'[]'`) - **向量维度**: 由 `knowledge.vector.dimension` 配置(默认 1024)。修改后需执行 `DROP TABLE IF EXISTS vector_store CASCADE` 重建向量表,并重新上传知识库文档。距离类型: COSINE_DISTANCE,索引: HNSW - **分块配置**: `knowledge.chunk.*` 配置项(`ChunkConfig`),默认 chunkSize=200, overlap=100, minChunkSizeChars=10, maxNumChunks=5000, keepSeparator=true +- **分块器 `OverlapTokenTextSplitter`**: Spring AI 的 `TokenTextSplitter` **不支持 overlap**(构造器与 Builder 均无该形参,社区 PR #4054 不向 1.x 回迁)。项目继承标准 `TextSplitter` 基类自研了 `OverlapTokenTextSplitter`(`document/transform/`),复刻 `TokenTextSplitter` 全部切分语义,仅把前进步长由 `chunkSize` 改为 `chunkSize - overlap`,并用 jtokkit(CL100K_BASE,与上游同库)做 token 编码。 + - **前进步长必须有下限(`minAdvance()`)**: 标点截断会缩短本块消耗的 token 数,而步长 = `消耗量 - overlap`;若截断点靠前,步长会被压到 1 个 token,分块数成倍膨胀(实测 `chunkSize=60/overlap=30` 时 349 块 vs 修复后 44 块)。因此**仅当截断后仍能前进至少 `(chunkSize - overlap) / 2` 个 token 时才采用该截断**,否则宁可切断句子。`overlap=0` 时该下限取 1,与标准 `TokenTextSplitter` 行为**逐块一致**(已有对比验证)。 + - **历史缺陷已修复**: 旧 `MyTokenTextSplitter` 因形参错位,把 `overlap` 传进了 `minChunkSizeChars` 位,导致 `knowledge.chunk.overlap` 从未生效。修复后重叠真正生效,**分块边界与块数会变化**,存量文档需重新分块+向量化(`POST /document/batch/reprocess`)。 + - `minChunkLengthToEmbed` 固定为 10(`DocumentProcessingService.MIN_CHUNK_LENGTH_TO_EMBED`,ChunkConfig 无对应配置项) - **上传校验**: `ALLOWED_EXTENSIONS` 白名单 + 50MB 大小限制(`spring.servlet.multipart` 配置),前后端双重校验 - **文档去重**: `KnowledgeDocument.contentHash` 字段(SHA-256),上传时自动计算并查重 - **数据库自动初始化**: `DatabaseInitConfig` 在启动时检查并创建 `knowledge_category`/`knowledge_document`/`ai_model_config` 等表,对已存在的 `knowledge_document` 表会自动补加 `content_hash` 列。注意 `knowledge-base.sql` 脚本为早期版本,缺少此列,实际以 `DatabaseInitConfig` 为准 @@ -92,14 +115,19 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支 - **ChatModel 运行时切换**: 通过 `ChatModelFactory` 按 DB 活跃配置动态创建/缓存 ChatModel(包括 DashScope,不再复用 yml 自动配置的 Bean),配置变更时立即生效(无需重启)。**OpenAI 兼容路径使用自定义 `completionsPath`**(与 EmbeddingModelFactory 的 embeddingsPath 对应),baseUrl 已含版本段的厂商(moonshot `/v1`、volcengine `/api/v3`、zhipu `/api/paas/v4`)设为 `/chat/completions`,其余使用默认 `/v1/chat/completions` - **EmbeddingModel 运行时切换**: 通过 `EmbeddingModelFactory` + `DynamicEmbeddingModel` 代理,按 DB 活跃配置动态创建/缓存 EmbeddingModel,`PgVectorStoreConfig` 和 `InMemoryVectorStoreConfig` 注入 `DynamicEmbeddingModel`,向量化模型配置变更后无需重启即可生效 - **多提供商支持**: DashScope(通义千问)+ OpenAI 兼容提供商(DeepSeek / 豆包 / Kimi / 智谱 / OpenAI),ChatModel 和 EmbeddingModel 均通过对应 API 手动构建 -- **缓存刷新**: 配置增删改激活时 Controller 自动调用 `ChatModelFactory.clearCache()` + `EmbeddingModelFactory.clearCache()` + `AssistantApp.clearCache()` +- **缓存刷新**: 模型配置增删改激活时 Controller 自动调用 `ChatModelFactory.clearCache()` + `EmbeddingModelFactory.clearCache()` + `AssistantApp.clearCache()`;MCP Server 增删改/启停/全量刷新时 `McpServerConfigController` 亦会调用 `AssistantApp.clearCache()`(避免继续使用旧的 MCP 工具集) - **动态模型列表获取**: `ModelListFetcher` 通过调用各提供商的 `/v1/models` 兼容端点(DashScope 用 `/compatible-mode/v1/models`),动态获取可用模型列表。前端填入 API Key + API 地址后,点击「获取模型」即可自动填充模型名称下拉列表(`` 支持搜索选择 + 自定义输入) ### 依赖版本 -- 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`,启动时检查 EMBEDDING 配置合理性、**校验 EmbeddingModel 实际维度与配置维度是否一致**,不一致时 WARN 告警并给出修复步骤。**不再强制修正非 DashScope 配置**,尊重用户在 DB 中配置的提供商和模型 @@ -148,6 +176,20 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支 **反面案例**: `knowledge_document.content_hash` 列仅在 `DatabaseInitConfig` 中迁移,未同步到 `knowledge-base.sql`,导致该脚本变为过时版本。 +### 后端:vector_store 物理删除(禁止在原生 SQL 中引用 is_delete) + +**规则**: `vector_store` 表由 Spring AI `PgVectorStore` 以 `initializeSchema(true)` 自动建表,默认只有 `id / content / metadata(json) / embedding` 四列;`DatabaseInitConfig` 仅额外补了 `content_tsvector`、`create_time` 两列。因此 **vector_store 只保证有六列**:`id / content / metadata / embedding / content_tsvector / create_time`,**没有 `is_delete`、没有 `update_time`**。 + +- vector_store 的向量删除走**物理删除**(`pgVectorVectorStore.delete(ids)`),不存在逻辑删除语义,原生 SQL 中**禁止**写 `AND is_delete = false`。 +- 凡是引用业务表(`knowledge_document` 等 MyBatis Plus 逻辑删除表)的 SQL 里常用的 `is_delete = false` 过滤,**不得照搬到 vector_store**。 +- 对 vector_store 写原生 SQL 前,先确认引用的列在上述六列白名单内。 + +**反面案例**: `HybridSearchService.keywordSearch()` 从 `knowledge_document` 表查询照搬了 `AND is_delete = false`,但 vector_store 无此列,PostgreSQL 报 `column "is_delete" does not exist`(SQLState 42703),被 Spring 统一翻译为 `BadSqlGrammarException`,前端显示「搜索失败: bad SQL grammar」,且仅关键词/混合模式触发(向量模式走 Spring AI similaritySearch 不执行该 SQL)。 + +### 后端:数据库初始化一律幂等自愈 + +**规则**: 新增列/索引/触发器的 init 方法(`DatabaseInitConfig` 内)**不要**「列已存在就 return」,应使用幂等 SQL 组合自愈:`ADD COLUMN IF NOT EXISTS` + 回填(`UPDATE ... WHERE xxx IS NULL`)+ `CREATE INDEX IF NOT EXISTS` + `CREATE OR REPLACE FUNCTION` + `DROP TRIGGER IF EXISTS` + `CREATE TRIGGER`。避免半途失败或对象被手动删除后留下静默缺口(如新文档 `content_tsvector` 恒为 NULL、检索静默返回空)。 + ### 后端:Long ID 序列化为字符串 **规则**: 所有雪花算法生成的 Long 类型 ID 字段,必须保证前端收到的是**字符串**而非数字,防止 JS 超过 `Number.MAX_SAFE_INTEGER`(2^53)精度丢失。 @@ -162,6 +204,41 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支 ### 后端:敏感字段脱敏 **规则**: API 返回用户对象前,必须将 `password` 等敏感字段置为 `null`。不得将 BCrypt 哈希暴露给前端。 +### 后端:列表排序字段白名单(防 SQL 注入) +**规则**: 任何用户可控的列表排序参数,必须经 `com.wok.supportbot.common.SortUtils` 的白名单映射解析,**严禁**将前端传入的 `sortField` 直接拼接进 `ORDER BY`。 + +| 场景 | 做法 | +|------|------| +| MyBatis Plus `QueryWrapper` | `String col = SortUtils.resolveColumn(sortField, 白名单Map, "create_time");` 再 `orderByAsc/orderByDesc(col)` | +| `LambdaQueryWrapper` | 其 `orderByAsc/orderByDesc` 只接受 `SFunction`、不接受字符串列名,需改用 `QueryWrapper` 做排序 | +| JdbcTemplate 原生 SQL | 同样用 `resolveColumn` 得到安全列名后拼 `ORDER BY col ASC/DESC` | +| `DISTINCT ON` 查询 | 排序需将原查询包成子查询,在外层按白名单列排序(见 `MessageFeedbackService.listConversationsByFeedback`) | + +白名单用 `Map.of(colKey, 列名)` 定义;`resolveColumn` 内部先判空(避免 `Map.of().get(null)` 抛 NPE),未知字段回退默认列(通常 `create_time`);`sortOrder` 仅 `asc` 视为升序,其余一律降序。 + +**反面案例**: 早期 `DocumentService.listDocuments` 手工写白名单,因未判 `sortField == null` 直接 `Map.of().getOrDefault(sortField, ...)` 触发 NPE,已由 `SortUtils.resolveColumn` 统一规避。 + +### 后端:Java 文本块拼 SQL 的边界空格陷阱 +**规则**: 用 Java 文本块(`"""`)拼 SQL 时,**严禁**让闭合定界符 `"""` 与内容行同尾、或让变量紧跟开启定界符 `"""` 拼接,否则空格/换行会被静默吞掉导致 SQL 粘连。Java 文本块有两个隐蔽行为: + +1. **闭合定界符前的尾随空格被剥离**:`ORDER BY """` 中 `BY` 后的空格会丢,`"ORDER BY "` 变成 `"ORDER BY"`。 +2. **开启定界符后的前导换行被消费**:`+ """` 后紧跟的下一文本块,其第一行前导换行不算内容,导致前一段末尾与后一段开头直接相连(如 `DESC` + `LIMIT` 变成 `DESCLIMIT`)。 + +**做法**: 涉及排序子句、动态变量拼接时,用普通字符串显式带空格拼接,不要依赖文本块边界: +```java +// ✅ 正确:显式空格 + 变量,不碰文本块边界 +""" ... ) t +""" + " ORDER BY " + orderBy + " LIMIT ? OFFSET ?"; +// ❌ 错误:ORDER BY 尾随空格被剥离、orderBy 与 LIMIT 粘连 +""" ... ) t +ORDER BY """ + orderBy + """ +LIMIT ? OFFSET ? +"""; +``` +`""" + whereClause` 这类「闭合定界符独立一行 + whereClause 自带前导空格」的写法是安全的,因为不依赖尾随空格。 + +**反面案例**: `MessageFeedbackService.listConversationsByFeedback` 用 `ORDER BY """ + orderBy + """` 拼接,编译后 `ORDER BY ` 尾随空格被剥离、`DESC` 与 `LIMIT` 粘连成 `ORDER BYfeedback_time DESCLIMIT`,PostgreSQL 报 `syntax error`(Spring 翻译为 `BadSqlGrammarException`),前端显示「查询失败:bad SQL grammar」。 + ### 前端:弹窗实现统一模式 项目中存在两种弹窗模式,**不可混用**: @@ -201,6 +278,21 @@ catch (e) { toast('操作失败', 'error') } **反面案例**: commit `527d9e7` 创建的流程图展示了 RAG 子图中 `VECTOR → RRF | KEYWORD → RRF | HYBRID → RRF → Reranker` 的三模式检索流程,但实际 `RagPipeline.similaritySearch()` 仅做纯向量检索,`HybridSearchService`/`RrfFusion`/`RerankerService` 尚未接入主对话流程,导致图表与代码事实不符。 +### 后端:CORS 双轨制(SDK 接口开放 / 管理接口白名单) + +**规则**: CORS 配置分两套,**不可整体收紧为单一白名单**(系统可用性第一): + +| 路径 | CORS 策略 | 原因 | +|------|----------|------| +| SDK 第三方接入接口:`/ai/**`、`/category/tree`、`/category/list`、`/feedback`、`/attachment/upload` | `allowedOriginPatterns("*")` 开放跨域 | 第三方域名动态未知,用 Bearer Token 鉴权,不依赖来源白名单 | +| 管理后台接口:其余 `/**` | `allowed-origins` 白名单 | 用 httpOnly refresh cookie,需白名单防跨域 CSRF | + +配置有两处,**必须同步修改**:`SecurityConfig.corsConfigurationSource()`(Security 链 CorsFilter)与 `CorsConfig.addCorsMappings()`(Spring MVC 层 CorsInterceptor)。两者均按注册顺序匹配,**先注册精确的 SDK 路径,再注册兜底的 `/**`**。 + +**反面案例**: commit `9338fcb` 为配合 refresh token 迁移 httpOnly Cookie,把 CORS 从 `allowedOriginPatterns("*")` 整体收紧为白名单,引发两个回归:(1) 同源静态资源 ` diff --git a/frontend/src/api/api-key.ts b/frontend/src/api/api-key.ts index 2af1346..e4f8ab9 100644 --- a/frontend/src/api/api-key.ts +++ b/frontend/src/api/api-key.ts @@ -1,8 +1,11 @@ import request from './request' import type { ApiResponse } from '@/types/api' -export function listApiKeys(page = 1, size = 20): Promise { - return request.get(`/api-key/list?page=${page}&size=${size}`).then(r => r.data) +export function listApiKeys(page = 1, size = 20, sortField?: string, sortOrder?: string): Promise { + let path = `/api-key/list?page=${page}&size=${size}` + if (sortField) path += `&sortField=${sortField}` + if (sortOrder) path += `&sortOrder=${sortOrder}` + return request.get(path).then(r => r.data) } /** 拉取全部 API Key(供筛选下拉,最多 1000 条) */ export function listAllApiKeys(): Promise { diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 8bec31d..ff6cbb7 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -6,9 +6,19 @@ export function login(username: string, password: string): Promise r.data) } -/** 刷新 Token */ -export function refreshToken(refreshToken: string): Promise> { - return request.post('/auth/refresh', { refreshToken }).then(r => r.data) +/** 刷新 Token(refresh token 通过 httpOnly Cookie 自动携带) */ +export function refreshToken(): Promise> { + return request.post('/auth/refresh').then(r => r.data) +} + +/** 退出登录(清除服务端 refresh token cookie) */ +export function logout(): Promise> { + return request.post('/auth/logout').then(r => r.data) +} + +/** 当前登录用户自助修改密码 */ +export function changePassword(oldPassword: string, newPassword: string): Promise> { + return request.put('/auth/password', { oldPassword, newPassword }).then(r => r.data) } /** 获取当前登录用户信息 */ diff --git a/frontend/src/api/conversation.ts b/frontend/src/api/conversation.ts index c73069c..68423ca 100644 --- a/frontend/src/api/conversation.ts +++ b/frontend/src/api/conversation.ts @@ -1,11 +1,13 @@ import request from './request' import type { ApiResponse } from '@/types/api' -export function listConversations(page = 1, size = 10, keyword?: string, accountId?: string, roleId?: string): Promise { +export function listConversations(page = 1, size = 10, keyword?: string, accountId?: string, roleId?: string, sortField?: string, sortOrder?: string): Promise { let path = `/conversation/list?page=${page}&size=${size}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` if (accountId) path += `&accountId=${encodeURIComponent(accountId)}` if (roleId) path += `&roleId=${encodeURIComponent(roleId)}` + if (sortField) path += `&sortField=${sortField}` + if (sortOrder) path += `&sortOrder=${sortOrder}` return request.get(path).then(r => r.data) } export function getConversationDetail(conversationId: string): Promise { return request.get(`/conversation/${conversationId}`).then(r => r.data) } diff --git a/frontend/src/api/document.ts b/frontend/src/api/document.ts index 53167ec..a35ff73 100644 --- a/frontend/src/api/document.ts +++ b/frontend/src/api/document.ts @@ -12,12 +12,14 @@ function authHeaders(): Record { // ==================== 文档 CRUD ==================== /** 文档列表(分页 + 过滤 + 搜索) */ -export function listDocuments(page = 1, size = 10, categoryId?: string, status?: string, keyword?: string, tag?: string): Promise { +export function listDocuments(page = 1, size = 10, categoryId?: string, status?: string, keyword?: string, folderId?: string, sortField?: string, sortOrder?: string): Promise { let path = `/document/list?page=${page}&size=${size}` if (categoryId) path += `&categoryId=${categoryId}` + if (folderId != null) path += `&folderId=${folderId}` if (status) path += `&status=${status}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` - if (tag) path += `&tag=${encodeURIComponent(tag)}` + if (sortField) path += `&sortField=${sortField}` + if (sortOrder) path += `&sortOrder=${sortOrder}` return request.get(path).then(r => r.data) } @@ -95,9 +97,9 @@ export function batchToggleDocuments(ids: string[], enabled: boolean): Promise r.data) } -/** 批量移动分类 */ -export function batchMoveDocuments(ids: string[], categoryId: string): Promise { - return request.post('/document/batch/move', { ids, categoryId }).then(r => r.data) +/** 批量移动(分类根或目录,folderId 为空/0 表示移动到分类根) */ +export function batchMoveDocuments(ids: string[], categoryId: string, folderId?: string): Promise { + return request.post('/document/batch/move', { ids, categoryId, folderId }).then(r => r.data) } /** 更新单个分块内容 */ @@ -125,10 +127,3 @@ export function searchDocuments(query: string, topK?: number, similarityThreshol export function getStats(): Promise { return request.get('/document/stats').then(r => r.data) } - -// ==================== 标签 ==================== - -/** 获取标签列表 */ -export function getTagList(): Promise { - return request.get('/tag/list').then(r => r.data) -} diff --git a/frontend/src/api/faq.ts b/frontend/src/api/faq.ts index 59eaf9f..64e47de 100644 --- a/frontend/src/api/faq.ts +++ b/frontend/src/api/faq.ts @@ -1,11 +1,13 @@ import request from './request' import type { ApiResponse } from '@/types/api' -export function listFaqs(page = 1, size = 20, keyword?: string, categoryId?: string, status?: string): Promise { +export function listFaqs(page = 1, size = 20, keyword?: string, categoryId?: string, status?: string, sortField?: string, sortOrder?: string): Promise { let path = `/faq/list?page=${page}&size=${size}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` if (categoryId) path += `&categoryId=${categoryId}` if (status) path += `&status=${encodeURIComponent(status)}` + if (sortField) path += `&sortField=${sortField}` + if (sortOrder) path += `&sortOrder=${sortOrder}` return request.get(path).then(r => r.data) } export function createFaq(data: any): Promise { return request.post('/faq', data).then(r => r.data) } diff --git a/frontend/src/api/folder.ts b/frontend/src/api/folder.ts new file mode 100644 index 0000000..02dce43 --- /dev/null +++ b/frontend/src/api/folder.ts @@ -0,0 +1,17 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' + +/** 获取目录树(可按分类过滤) */ +export function getFolderTree(categoryId?: string): Promise { return request.get(`/folder/tree${categoryId ? `?categoryId=${categoryId}` : ''}`).then(r => r.data) } + +/** 获取目录列表(可按分类过滤) */ +export function getFolderList(categoryId?: string): Promise { return request.get(`/folder/list${categoryId ? `?categoryId=${categoryId}` : ''}`).then(r => r.data) } + +/** 创建目录 */ +export function createFolder(data: any): Promise { return request.post('/folder', data).then(r => r.data) } + +/** 更新目录 */ +export function updateFolder(id: string, data: any): Promise { return request.put(`/folder/${id}`, data).then(r => r.data) } + +/** 删除目录 */ +export function deleteFolder(id: string): Promise { return request.delete(`/folder/${id}`).then(r => r.data) } diff --git a/frontend/src/api/llm-trace.ts b/frontend/src/api/llm-trace.ts index 6b73175..72cdf6d 100644 --- a/frontend/src/api/llm-trace.ts +++ b/frontend/src/api/llm-trace.ts @@ -13,6 +13,8 @@ export interface LlmTraceQuery { keyword?: string apiKeyId?: string errorType?: string + sortField?: string + sortOrder?: string } /** 分页查询调用记录 */ @@ -28,6 +30,8 @@ export function listLlmTraces(query: LlmTraceQuery = {}): Promise { if (query.keyword) params.set('keyword', query.keyword) if (query.apiKeyId) params.set('apiKeyId', query.apiKeyId) if (query.errorType) params.set('errorType', query.errorType) + if (query.sortField) params.set('sortField', query.sortField) + if (query.sortOrder) params.set('sortOrder', query.sortOrder) return request.get(`/llm-trace/list?${params.toString()}`).then(r => r.data) } diff --git a/frontend/src/api/mcp-server.ts b/frontend/src/api/mcp-server.ts index b098b67..1f27ab5 100644 --- a/frontend/src/api/mcp-server.ts +++ b/frontend/src/api/mcp-server.ts @@ -1,12 +1,17 @@ import request from './request' import type { ApiResponse } from '@/types/api' -export function listMcpServers(page = 1, size = 10): Promise { return request.get(`/mcp-server/list?page=${page}&size=${size}`).then(r => r.data) } +export function listMcpServers(page = 1, size = 10, sortField?: string, sortOrder?: string): Promise { + let path = `/mcp-server/list?page=${page}&size=${size}` + if (sortField) path += `&sortField=${sortField}` + if (sortOrder) path += `&sortOrder=${sortOrder}` + return request.get(path).then(r => r.data) +} export function getMcpServerDetail(id: string): Promise { return request.get(`/mcp-server/${id}`).then(r => r.data) } export function createMcpServer(data: any): Promise { return request.post('/mcp-server', data).then(r => r.data) } export function updateMcpServer(id: string, data: any): Promise { return request.put(`/mcp-server/${id}`, data).then(r => r.data) } export function deleteMcpServer(id: string): Promise { return request.delete(`/mcp-server/${id}`).then(r => r.data) } -export function toggleMcpServer(id: string, active: boolean): Promise { return request.put(`/mcp-server/${id}/toggle?active=${active}`).then(r => r.data) } +export function toggleMcpServer(id: string, active: boolean): Promise { return request.put(`/mcp-server/${id}/toggle`, { active }).then(r => r.data) } export function testMcpServer(id: string): Promise { return request.post(`/mcp-server/${id}/test`, {}).then(r => r.data) } export function refreshMcpServers(): Promise { return request.post('/mcp-server/refresh', {}).then(r => r.data) } export function listAvailableMcpTools(): Promise { return request.get('/mcp-server/tools').then(r => r.data) } diff --git a/frontend/src/api/model-config.ts b/frontend/src/api/model-config.ts index 17c78d9..743e6d6 100644 --- a/frontend/src/api/model-config.ts +++ b/frontend/src/api/model-config.ts @@ -1,9 +1,11 @@ import request from './request' import type { ApiResponse } from '@/types/api' -export function listModelConfigs(page = 1, size = 10, appType?: string): Promise { +export function listModelConfigs(page = 1, size = 10, appType?: string, sortField?: string, sortOrder?: string): Promise { let path = `/model-config/list?page=${page}&size=${size}` if (appType) path += `&appType=${encodeURIComponent(appType)}` + if (sortField) path += `&sortField=${sortField}` + if (sortOrder) path += `&sortOrder=${sortOrder}` return request.get(path).then(r => r.data) } export function getModelConfigDetail(id: string): Promise { return request.get(`/model-config/${id}`).then(r => r.data) } diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index d38101d..f878ef9 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -3,13 +3,15 @@ * 替代旧 api.js 中的 fetch 封装 */ import axios from 'axios' -import { getToken } from '@/utils/token' +import { getToken, setToken, clearTokens } from '@/utils/token' /** 同源部署,API 基址为空 */ const request = axios.create({ baseURL: '', timeout: 30000, headers: { 'Content-Type': 'application/json' }, + // 允许跨域携带 Cookie(refresh token 使用 httpOnly Cookie) + withCredentials: true, }) /** 请求拦截器:自动附加 Bearer Token */ @@ -22,18 +24,74 @@ request.interceptors.request.use((config) => { }) /** - * 响应拦截器:401 触发 auth:unauthorized 事件 - * 与旧版 app.js 中的 Token 刷新逻辑一致 + * Token 刷新锁与等待队列: + * - 多个请求并发 401 时,只有第一个发起 /auth/refresh,其余挂起等待新 token 后重试。 + * - 刷新成功后重试原请求,调用方无感知。 + */ +let isRefreshing = false +let pendingQueue: Array<(token: string) => void> = [] + +/** + * 响应拦截器:401 自动刷新 access token 并重试原请求 + * 注意:/auth/login 与 /auth/refresh 自身的 401 不做刷新处理,由调用方自行处理,避免递归。 */ request.interceptors.response.use( (response) => response, - (error) => { - if (error.response?.status === 401) { - localStorage.removeItem('sb_token') - localStorage.removeItem('sb_user') - window.dispatchEvent(new Event('auth:unauthorized')) + async (error) => { + const { config, response } = error + const url: string = config?.url || '' + + if (response?.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) { + // 重试上限:已经过「401 → refresh → 重试」一次的请求若仍 401,不再二次刷新, + // 直接失败,斩断 401→refresh→401 的自激死循环(配合 _retry 标记,见下方挂起分支) + if ((config as any)._retry) { + return Promise.reject(new Error(response?.data?.message || error.message || '登录已过期,请重新登录')) + } + // 标记本次请求已进入刷新流程(含挂起等待者),确保每个请求至多重试一次 + const retriedConfig = config as any + retriedConfig._retry = true + + // 已有刷新在进行中,挂起当前请求,等待新 token 后重试 + if (isRefreshing) { + return new Promise((resolve, reject) => { + pendingQueue.push((token: string) => { + if (token) { + resolve(request(config)) + } else { + reject(new Error(response?.data?.message || error.message || '网络错误')) + } + }) + }) + } + + // 发起刷新(用「裸 axios」而不走当前实例,避免 refresh 自身再次进入本拦截器造成递归; + // 其 401 也会被上方 url 判断跳过,不会二次刷新) + isRefreshing = true + try { + const res = await axios.post('/auth/refresh', null, { withCredentials: true }) + const newToken = res.data?.data?.accessToken + if (newToken) { + setToken(newToken) + // 放行所有挂起请求(请求拦截器会自动附加新 token) + pendingQueue.forEach((cb) => cb(newToken)) + pendingQueue = [] + // 重试当前请求(config._retry 已为 true,若仍 401 会直接失败而不再刷新) + return request(config) + } + throw new Error('刷新令牌失败') + } catch (refreshErr) { + // 刷新失败:清除本地凭证并触发全局登出 + clearTokens() + pendingQueue.forEach((cb) => cb('')) + pendingQueue = [] + window.dispatchEvent(new Event('auth:unauthorized')) + return Promise.reject(new Error(response?.data?.message || error.message || '网络错误')) + } finally { + isRefreshing = false + } } - const message = error.response?.data?.message || error.message || '网络错误' + + const message = response?.data?.message || error.message || '网络错误' return Promise.reject(new Error(message)) } ) diff --git a/frontend/src/api/sensitive-word.ts b/frontend/src/api/sensitive-word.ts index 6a97f9d..12f419c 100644 --- a/frontend/src/api/sensitive-word.ts +++ b/frontend/src/api/sensitive-word.ts @@ -1,11 +1,13 @@ import request from './request' import type { ApiResponse } from '@/types/api' -export function listSensitiveWords(page = 1, size = 20, keyword?: string, category?: string, level?: number): Promise { +export function listSensitiveWords(page = 1, size = 20, keyword?: string, category?: string, level?: number, sortField?: string, sortOrder?: string): Promise { let path = `/sensitive-word/list?page=${page}&size=${size}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` if (category) path += `&category=${encodeURIComponent(category)}` if (level !== undefined && level !== null) path += `&level=${level}` + if (sortField) path += `&sortField=${sortField}` + if (sortOrder) path += `&sortOrder=${sortOrder}` return request.get(path).then(r => r.data) } export function createSensitiveWord(data: any): Promise { return request.post('/sensitive-word', data).then(r => r.data) } @@ -14,8 +16,10 @@ export function updateSensitiveWord(id: string, data: any): Promise export function toggleSensitiveWord(id: string, isActive: boolean): Promise { return updateSensitiveWord(id, { isActive }) } export function deleteSensitiveWord(id: string): Promise { return request.delete(`/sensitive-word/${id}`).then(r => r.data) } export function batchImportSensitiveWords(data: { words: string[]; category: string; level: number }): Promise { return request.post('/sensitive-word/batch-import', data).then(r => r.data) } -export function listAuditLogs(page = 1, size = 20, sessionId?: string): Promise { +export function listAuditLogs(page = 1, size = 20, sessionId?: string, sortField?: string, sortOrder?: string): Promise { let path = `/sensitive-word/audit-log?page=${page}&size=${size}` if (sessionId) path += `&sessionId=${encodeURIComponent(sessionId)}` + if (sortField) path += `&sortField=${sortField}` + if (sortOrder) path += `&sortOrder=${sortOrder}` return request.get(path).then(r => r.data) } diff --git a/frontend/src/api/upload.ts b/frontend/src/api/upload.ts index 52598a4..114d125 100644 --- a/frontend/src/api/upload.ts +++ b/frontend/src/api/upload.ts @@ -1,26 +1,22 @@ -import { getToken } from '@/utils/token' +import request from './request' import type { ApiResponse } from '@/types/api' -/** XMLHttpRequest 上传(带进度回调)—— 保留旧版 api.js 的核心实现 */ -function postFormWithProgress(path: string, formData: FormData, onProgress?: (pct: number) => void): Promise { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() - xhr.open('POST', path) - const token = getToken() - if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token) - xhr.upload.addEventListener('progress', (e) => { - if (e.lengthComputable && onProgress) { +/** + * 表单上传(带进度回调)—— 基于 axios request 实例, + * 复用请求拦截器(自动附加 Bearer Token)与响应拦截器(401 自动刷新后重试)。 + * 进度通过 axios onUploadProgress 获取。 + */ +function postFormWithProgress(path: string, formData: FormData, onProgress?: (pct: number) => void): Promise { + return request.post(path, formData, { + // 必须显式指定 multipart/form-data:request 实例默认 Content-Type 为 application/json, + // 若不覆盖,后端会将请求视为非 multipart 而抛 MultipartException(边界由浏览器自动补充)。 + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (e) => { + if (e.total && onProgress) { onProgress(Math.round((e.loaded / e.total) * 100)) } - }) - xhr.addEventListener('load', () => { - if (xhr.status === 401) { localStorage.removeItem('sb_token'); localStorage.removeItem('sb_user'); window.dispatchEvent(new Event('auth:unauthorized')); reject(new Error('401')); return } - try { resolve(JSON.parse(xhr.responseText)) } catch { reject(new Error('响应解析失败')) } - }) - xhr.addEventListener('error', () => reject(new Error('网络错误'))) - xhr.addEventListener('abort', () => reject(new Error('上传已取消'))) - xhr.send(formData) - }) + }, + }).then(r => r.data) } /** 上传文件(带进度) */ @@ -28,6 +24,11 @@ export function uploadFile(formData: FormData, onProgress?: (pct: number) => voi return postFormWithProgress('/upload/file', formData, onProgress) } +/** 上传文件夹(批量文件 + 相对路径,复制目录结构) */ +export function uploadFolder(formData: FormData, onProgress?: (pct: number) => void): Promise { + return postFormWithProgress('/upload/folder', formData, onProgress) +} + /** 上传 Markdown(带进度) */ export function uploadMarkdown(formData: FormData, onProgress?: (pct: number) => void): Promise { return postFormWithProgress('/upload/markdown', formData, onProgress) diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index d2b7ede..1974b7c 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -1,10 +1,12 @@ import request from './request' import type { ApiResponse } from '@/types/api' -export function listSysUsers(page = 1, size = 20, keyword?: string, enabled?: boolean): Promise { +export function listSysUsers(page = 1, size = 20, keyword?: string, enabled?: boolean, sortField?: string, sortOrder?: string): Promise { let path = `/sys-user/list?page=${page}&size=${size}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` if (enabled !== undefined && enabled !== null) path += `&enabled=${enabled}` + if (sortField) path += `&sortField=${sortField}` + if (sortOrder) path += `&sortOrder=${sortOrder}` return request.get(path).then(r => r.data) } export function getSysUserDetail(id: string): Promise { return request.get(`/sys-user/${id}`).then(r => r.data) } diff --git a/frontend/src/api/webhook.ts b/frontend/src/api/webhook.ts index c06a670..a60e60e 100644 --- a/frontend/src/api/webhook.ts +++ b/frontend/src/api/webhook.ts @@ -1,7 +1,12 @@ import request from './request' import type { ApiResponse } from '@/types/api' -export function listWebhooks(page = 1, size = 20): Promise { return request.get(`/webhook/list?page=${page}&size=${size}`).then(r => r.data) } +export function listWebhooks(page = 1, size = 20, sortField?: string, sortOrder?: string): Promise { + let path = `/webhook/list?page=${page}&size=${size}` + if (sortField) path += `&sortField=${sortField}` + if (sortOrder) path += `&sortOrder=${sortOrder}` + return request.get(path).then(r => r.data) +} export function createWebhook(data: any): Promise { return request.post('/webhook', data).then(r => r.data) } export function updateWebhook(id: string, data: any): Promise { return request.put(`/webhook/${id}`, data).then(r => r.data) } export function deleteWebhook(id: string): Promise { return request.delete(`/webhook/${id}`).then(r => r.data) } diff --git a/frontend/src/components/BaseTable.vue b/frontend/src/components/BaseTable.vue new file mode 100644 index 0000000..c8a715f --- /dev/null +++ b/frontend/src/components/BaseTable.vue @@ -0,0 +1,40 @@ + + + diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 73e60f2..b7e250d 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -50,8 +50,8 @@ const mobileMenuOpen = ref(false) // 提供给子组件全局弹窗能力 provide('openDocDetail', (docId: string) => dialog.openDetail(docId)) -function handleLogout() { - auth.logout() +async function handleLogout() { + await auth.doLogout() toast('已退出登录', 'info') router.push('/chat') } diff --git a/frontend/src/layouts/Topbar.vue b/frontend/src/layouts/Topbar.vue index bfea6e0..ea173e8 100644 --- a/frontend/src/layouts/Topbar.vue +++ b/frontend/src/layouts/Topbar.vue @@ -13,21 +13,108 @@ {{ displayName }} {{ r }} + 改密 退出 + + + + + + + + + + + + + + diff --git a/frontend/src/views/ConversationManager.vue b/frontend/src/views/ConversationManager.vue index 59ddef7..5d48579 100644 --- a/frontend/src/views/ConversationManager.vue +++ b/frontend/src/views/ConversationManager.vue @@ -17,8 +17,8 @@ 刷新 - + - + @@ -87,7 +87,7 @@ import { useConfirm } from '@/composables/useConfirm' const { confirm } = useConfirm() -const conversations=ref([]);const page=ref(1);const pageSize=ref(10);const total=ref(0) +const conversations=ref([]);const page=ref(1);const pageSize=ref(10);const total=ref(0);const loading=ref(false) const keyword=ref('');const accountFilter=ref('');const roleFilter=ref('') const roles=ref([]);const stats=ref(null) const msgModal=ref({visible:false,conversationId:'',roleName:'',messages:[] as any[]}) @@ -112,7 +112,8 @@ const canSubmitFaq = computed(() => { return false }) -const columns=[{colKey:'conversationId',title:'会话ID',width:160,ellipsis:true},{colKey:'accountId',title:'外部用户ID',width:100},{colKey:'roleName',title:'角色',width:80},{colKey:'messageCount',title:'消息数',width:60},{colKey:'lastMessageTime',title:'最后消息时间',width:140,cell:(_:any,{row}:any)=>formatDate(row.lastMessageTime)},{colKey:'lastMessagePreview',title:'最后消息预览',width:250,ellipsis:true},{colKey:'op',title:'操作',width:200}] +const columns=[{colKey:'conversationId',title:'会话ID',width:160,ellipsis:true,sorter:true},{colKey:'accountId',title:'外部用户ID',width:100,sorter:true},{colKey:'roleName',title:'角色',width:80,sorter:true},{colKey:'messageCount',title:'消息数',width:60,sorter:true},{colKey:'lastMessageTime',title:'最后消息时间',width:140,sorter:true,cell:(_:any,{row}:any)=>formatDate(row.lastMessageTime)},{colKey:'lastMessagePreview',title:'最后消息预览',width:250,ellipsis:true},{colKey:'op',title:'操作',width:200}] +const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null) onMounted(()=>{loadFilters();load();loadStats();loadCategories()}) @@ -130,9 +131,10 @@ function reasonLabel(c: string): string { return map[c] || c } -async function load(p=1){page.value=p - try{const r=await listConversations(p,pageSize.value,keyword.value||undefined,accountFilter.value||undefined,roleFilter.value||undefined);if(r.success){conversations.value=r.data||[];total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}} +async function load(p=1){page.value=p;loading.value=true + try{const r=await listConversations(p,pageSize.value,keyword.value||undefined,accountFilter.value||undefined,roleFilter.value||undefined,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined);if(r.success){conversations.value=r.data||[];total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}} function onPageChange(i:{current:number;pageSize:number}){pageSize.value=i.pageSize;load(i.current)} +function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;load(1)} async function loadFilters(){try{const r=await getRoleList();if(r.success){roles.value=r.data||[];roleOpts.value=[{label:'全部角色',value:''},...roles.value.map((ro:any)=>({label:ro.name,value:String(ro.id)}))]}}catch(e){console.error(e)}} diff --git a/frontend/src/views/DashboardPanel.vue b/frontend/src/views/DashboardPanel.vue index 0049ed6..a9ecde0 100644 --- a/frontend/src/views/DashboardPanel.vue +++ b/frontend/src/views/DashboardPanel.vue @@ -34,7 +34,7 @@ - + @@ -79,8 +79,8 @@ const satisfactionChartRef = ref(null) const hitColumns = [ { colKey: 'idx', title: '排名', width: 50, cell: (_h: any, { rowIndex }: any) => rowIndex + 1 }, { colKey: 'document_title', title: '文档标题', ellipsis: true }, - { colKey: 'hit_count', title: '命中次数', width: 80, cell: (_h: any, { row }: any) => row.hit_count ?? row.cnt ?? 0 }, - { colKey: 'avg_score', title: '平均得分', width: 80, cell: (_h: any, { row }: any) => row.avg_score != null ? Number(row.avg_score).toFixed(3) : '-' }, + { colKey: 'hit_count', title: '命中次数', width: 80, cell: (_h: any, { row }: any) => row.hit_count ?? row.cnt ?? 0, sorter: (a: any, b: any) => (a.hit_count ?? 0) - (b.hit_count ?? 0) }, + { colKey: 'avg_score', title: '平均得分', width: 80, cell: (_h: any, { row }: any) => row.avg_score != null ? Number(row.avg_score).toFixed(3) : '-', sorter: (a: any, b: any) => (a.avg_score ?? 0) - (b.avg_score ?? 0) }, ] const satisfactionColor = computed(() => { diff --git a/frontend/src/views/DocDetail.vue b/frontend/src/views/DocDetail.vue index 67a46e1..6ba682d 100644 --- a/frontend/src/views/DocDetail.vue +++ b/frontend/src/views/DocDetail.vue @@ -19,16 +19,14 @@ 标签 {{ t }} -
- 错误信息{{ doc.errorMessage }} -
+
{{ doc.sourceName || doc.title || '原文' }} - 内容已截断(仅显示前2000字符) + 内容已截断(仅显示前2000字符)
{{ doc.content }}
@@ -36,7 +34,7 @@
-
{{ chunksError }}
+
共 {{ chunks.length }} 个分块
@@ -104,8 +102,6 @@ function getTags(): string[] { .info-label { width: 70px; flex: none; font-size: 13px; color: var(--color-text-tertiary); } .info-val { font-size: 13px; color: var(--color-text-primary); word-break: break-all; } .info-tags { margin-top: 12px; display: flex; align-items: center; } -.info-error { margin-top: 8px; display: flex; align-items: flex-start; gap: 8px; } -.error-text { color: var(--color-error); font-size: 12px; } /* 原文 */ .raw-content { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; overflow: hidden; } @@ -125,6 +121,5 @@ function getTags(): string[] { .chunk-footer { margin-top: 8px; padding-top: 6px; border-top: 1px dashed var(--color-border); display: flex; justify-content: flex-end; } .chunk-len { font-size: 11px; color: var(--color-text-disabled); } -.error-msg { color: var(--color-error); padding: 8px 0; } .empty-detail { color: var(--color-text-tertiary); padding: 40px; text-align: center; } diff --git a/frontend/src/views/DocList.vue b/frontend/src/views/DocList.vue index c402af8..0a2377d 100644 --- a/frontend/src/views/DocList.vue +++ b/frontend/src/views/DocList.vue @@ -1,74 +1,158 @@ diff --git a/frontend/src/views/DocUpload.vue b/frontend/src/views/DocUpload.vue index 498b485..991f937 100644 --- a/frontend/src/views/DocUpload.vue +++ b/frontend/src/views/DocUpload.vue @@ -5,6 +5,18 @@
+
{{ tag }} - + :size-limit="{ size: 50, unit: 'MB', message: '文件大小不能超过 50MB' }"> + + + + @@ -35,10 +57,13 @@ diff --git a/frontend/src/views/FaqManager.vue b/frontend/src/views/FaqManager.vue index 59b0b84..fff1d41 100644 --- a/frontend/src/views/FaqManager.vue +++ b/frontend/src/views/FaqManager.vue @@ -19,8 +19,8 @@
- + - + void } const columns = [ - { colKey: 'question', title: '问题', width: 200, ellipsis: true }, + { colKey: 'question', title: '问题', width: 200, ellipsis: true, sorter: true }, { colKey: 'answer', title: '答案(摘要)', width: 200, ellipsis: true, cell: (_h: any, { row }: any) => stripMarkdown(row.answer || '') }, { colKey: 'categoryName', title: '分类', width: 100, cell: (_h:any,{row}:any)=>categoryMap.value[row.categoryId]||'-' }, { colKey: 'source', title: '来源', width: 70, cell: (_h:any,{row}:any)=>sourceLabel(row.source) }, - { colKey: 'priority', title: '优先级', width: 60 }, - { colKey: 'hitCount', title: '命中', width: 55 }, - { colKey: 'status', title: '状态', width: 75 }, + { colKey: 'priority', title: '优先级', width: 60, sorter: true }, + { colKey: 'hitCount', title: '命中', width: 55, sorter: true }, + { colKey: 'status', title: '状态', width: 75, sorter: true }, { colKey: 'op', title: '操作', width: 120 }, ] +const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null) const statusOptions = [{label:'全部状态',value:''},{label:'启用',value:'ENABLED'},{label:'禁用',value:'DISABLED'}] const categoryOptions = computed(()=>[{label:'全部分类',value:''},...flatCategories.value.map(c=>({label:c.path,value:String(c.id)}))]) @@ -159,13 +160,14 @@ function flattenTree(tree: any[], prefix = ''): any[] { } async function loadList() { loading.value = true - try { const r = await listFaqs(page.value,pageSize.value,searchKeyword.value||undefined,filterCategoryId.value||undefined,filterStatus.value||undefined) + try { const r = await listFaqs(page.value,pageSize.value,searchKeyword.value||undefined,filterCategoryId.value||undefined,filterStatus.value||undefined,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined) if (r.success) { faqs.value = r.data?.records||r.data||[]; total.value = r.data?.total||r.total||0 } } catch(e:any){ toast('加载失败:'+e.message,'error') } finally { loading.value = false } } async function loadStats() { try { const r = await getFaqStats(); if(r.success) stats.value=r.data } catch {/* */} } function onPageChange(info:{current:number;pageSize:number}) { page.value=info.current; pageSize.value=info.pageSize; loadList() } +function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;loadList()} const debouncedSearch = debounce(() => { page.value = 1; loadList() }) /** 表单当前值快照,用于判断是否有未保存修改 */ diff --git a/frontend/src/views/FeedbackOps.vue b/frontend/src/views/FeedbackOps.vue index 44e6985..ffc2064 100644 --- a/frontend/src/views/FeedbackOps.vue +++ b/frontend/src/views/FeedbackOps.vue @@ -11,8 +11,8 @@
- - + @@ -133,15 +133,16 @@ const processedOptions = [ ] const columns = [ - { colKey: 'feedbackType', title: '反馈', width: 90, cell: 'feedbackType' }, - { colKey: 'reasonCategory', title: '原因', width: 80, cell: 'reasonCategory' }, + { colKey: 'feedbackType', title: '反馈', width: 90, cell: 'feedbackType', sorter: true }, + { colKey: 'reasonCategory', title: '原因', width: 80, cell: 'reasonCategory', sorter: true }, { colKey: 'reasonComment', title: '补充说明', width: 160, ellipsis: true, cell: 'reasonComment' }, { colKey: 'userQuestion', title: '用户问题', width: 200, ellipsis: true, cell: 'userQuestion' }, { colKey: 'assistantAnswer', title: 'AI 回复', width: 200, ellipsis: true, cell: 'assistantAnswer' }, - { colKey: 'processed', title: '状态', width: 70, cell: 'processed' }, - { colKey: 'feedbackTime', title: '时间', width: 150, cell: (_: any, { row }: any) => formatDate(row.feedbackTime) }, + { colKey: 'processed', title: '状态', width: 70, cell: 'processed', sorter: true }, + { colKey: 'feedbackTime', title: '时间', width: 150, cell: (_: any, { row }: any) => formatDate(row.feedbackTime), sorter: true }, { colKey: 'op', title: '操作', width: 240, cell: 'op' }, ] +const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null) const canSubmit = computed(() => { const m = faqModal.value @@ -201,6 +202,8 @@ async function loadList(p: number) { page.value = p; loading.value = true try { const params: Record = { page: p, size: pageSize.value } + if (sortInfo.value?.sortBy) params.sortField = sortInfo.value.sortBy + if (sortInfo.value) params.sortOrder = sortInfo.value.descending ? 'desc' : 'asc' if (filterFeedbackType.value) params.feedbackType = filterFeedbackType.value if (filterReasonCategory.value) params.reasonCategory = filterReasonCategory.value if (filterProcessed.value) params.processed = filterProcessed.value === 'true' @@ -222,6 +225,7 @@ async function loadList(p: number) { } function onPageChange(i: { current: number; pageSize: number }) { pageSize.value = i.pageSize; loadList(i.current) } +function onSortChange(s: any) { sortInfo.value = s && s.sortBy ? { sortBy: s.sortBy, descending: !!s.descending } : null; loadList(1) } async function viewDetail(row: any) { try { diff --git a/frontend/src/views/LoginPage.vue b/frontend/src/views/LoginPage.vue index 2780bd8..a7fbf47 100644 --- a/frontend/src/views/LoginPage.vue +++ b/frontend/src/views/LoginPage.vue @@ -14,7 +14,7 @@ 登 录 - +
@@ -23,7 +23,7 @@ +.ellipsis{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;} diff --git a/frontend/src/views/ModelConfigManager.vue b/frontend/src/views/ModelConfigManager.vue index 5733860..b4f98d6 100644 --- a/frontend/src/views/ModelConfigManager.vue +++ b/frontend/src/views/ModelConfigManager.vue @@ -35,13 +35,12 @@
🔗 Fallback 链(按优先级排列) - ❓ 帮助 -
-
-
📖 Fallback 链使用说明
-

Fallback 链是高可用性机制,主模型故障时自动切换到备用模型。

-

✔ 勾选「设为活跃配置」启用 | 📊 调整「优先级」数值(越大越优先)| 🔀 拖拽列表项快速调整

+ ❓ 帮助
+ +

Fallback 链是高可用性机制,主模型故障时自动切换到备用模型。

+

✔ 勾选「设为活跃配置」启用 | 📊 调整「优先级」数值(越大越优先)| 🔀 拖拽列表项快速调整

+
{{ getAppTypeLabel(chain.appType) }}
-
- + @page-change="onPageChange" @sort-change="onSortChange"> @@ -97,24 +95,14 @@ - -
- - -
-
{{ testResult.success ? '✅ 连接成功' : '❌ 连接失败' }}
-
延迟:{{ testResult.latencyMs }}ms · 维度:{{ testResult.dimensions }}
-
{{ testResult.errorMessage }}
-
+ ✕ - - {{ fetchLoading ? '⏳ 获取中...' : '🔍 获取模型' }} + 获取模型 @@ -266,8 +254,8 @@ @@ -300,8 +288,7 @@ diff --git a/frontend/src/views/PipelineFlow.vue b/frontend/src/views/PipelineFlow.vue index cbaacd3..15eace8 100644 --- a/frontend/src/views/PipelineFlow.vue +++ b/frontend/src/views/PipelineFlow.vue @@ -26,13 +26,11 @@
-
-

⚠️ 图表渲染失败: {{ error }}

- 重试 -
-
-

⏳ 正在生成流程图...

-
+ + + + +
@@ -40,7 +38,7 @@ - - - - - + + + + + + diff --git a/src/main/resources/support-bot.sql b/src/main/resources/support-bot.sql index deeea1f..5a700a1 100644 --- a/src/main/resources/support-bot.sql +++ b/src/main/resources/support-bot.sql @@ -1,7 +1,9 @@ -- ================================================================ --- Support Bot 数据库初始化脚本 --- 版本: 1.0 --- 说明: 创建智能客服系统所需的数据库表和索引 +-- Support Bot 数据库初始化脚本(已废弃) +-- ⚠️ 本脚本为早期版本,仅含 chat_message / vector_store 两张表, +-- 已被 init-database.sql + DatabaseInitConfig.java 取代,请勿使用。 +-- 数据库初始化以 DatabaseInitConfig.java 自动执行为准, +-- 手动初始化请使用 init-database.sql。 -- ================================================================ -- 检查并创建必要的扩展 @@ -11,9 +13,7 @@ CREATE EXTENSION IF NOT EXISTS vector; -- ================================================================ -- 聊天消息表 - 存储用户与AI的对话记录 -- ================================================================ -DROP TABLE IF EXISTS chat_message CASCADE; - -CREATE TABLE chat_message ( +CREATE TABLE IF NOT EXISTS chat_message ( id BIGSERIAL PRIMARY KEY, conversation_id VARCHAR(64) NOT NULL, message_type VARCHAR(20) NOT NULL, @@ -50,9 +50,7 @@ ALTER TABLE chat_message ADD CONSTRAINT chk_message_type -- ================================================================ -- 向量存储表 - 存储文档向量和元数据用于RAG检索 -- ================================================================ -DROP TABLE IF EXISTS vector_store CASCADE; - -CREATE TABLE vector_store ( +CREATE TABLE IF NOT EXISTS vector_store ( id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, content TEXT NOT NULL, metadata JSONB NOT NULL DEFAULT '{}',