Browse Source

一期-雪花算法生成的 Long 类型 ID(18-19 位数字)超过 JavaScript 安全整数范围(2^53-1),JSON

反序列化时精度丢失,导致前端显示和传参的 ID 与数据库中不一致。
master
wanghanlin 2 days ago
parent
commit
54f6da84c8
  1. 74
      CLAUDE.md
  2. 12
      frontend.html
  3. 4
      src/main/java/com/wok/supportbot/entity/KnowledgeCategory.java
  4. 4
      src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java
  5. 12
      src/main/resources/static/frontend.html

74
CLAUDE.md

@ -0,0 +1,74 @@
# 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` 表由 `DatabaseInitConfig` 自动创建,无需手动建表。
## 核心架构决策
### 主启动类排除了 PgVectorStoreAutoConfiguration
`SupportBotApplication.java``@SpringBootApplication(exclude = PgVectorStoreAutoConfiguration.class)`,因为项目在 `PgVectorStoreConfig` 中手动配置 PgVectorStore Bean(标记 `@Primary`),不使用自动配置。另有一个 `InMemoryVectorStoreConfig` 作为开发备选。
### Spring AI 集成模式
- **ChatClient Builder**: 所有对话通过 `ChatClient.builder(dashscopeChatModel)` 构建
- **Advisor 链**: `MessageChatMemoryAdvisor`(记忆) → `MyLoggerAdvisor`(日志) → `QuestionAnswerAdvisor`(RAG)
- **结构化输出**: `ProductInfoApp` 使用 `.entity(ProductInfo.class)` 提取结构化数据
- **SSE 流式**: 三种实现 — Flux\<String\>、Flux\<ServerSentEvent\>、SseEmitter
### ChatMemory 持久化
当前使用 `DatabaseChatMemory`(PostgreSQL 持久化),`FileBasedChatMemory`(Kryo 序列化)已注释掉。`ProductInfoApp` 单独使用 `InMemoryChatMemory`
### RAG 双模式
1. **QuestionAnswerAdvisor 模式**(生产使用): 预检索优化 + `QuestionAnswerAdvisor`
2. **RetrievalAugmentationAdvisor 模式**(实验性): `doChatWithRagEnhance()``queryTransformers``multiQueryExpander` 未生效
### 文档处理管道
`DocumentService.uploadDocument()` 统一流程:文档提取 → `MyTokenTextSplitter` 分块 → `MyKeywordEnricher` AI 关键词提取 → `pgVectorVectorStore.add()` 向量化存储。每个分块的 metadata 中注入 `documentId`、`chunkIndex`、`sourceName`、`title` 以关联 `knowledge_document` 表。
### 预检索查询优化
四种策略在 `rag/preretrieval/` 下,由 `AssistantApp.doChatWithRagStrategy()` 根据 `strategy` 参数动态选择:REWRITE / TRANSLATION / COMPRESSION / MULTI_QUERY。另存在 Bean 配置版本(`QueryTransformerConfig`、`QueryExpanderConfig`),但实际使用自定义 Rewriter 组件。
## 关键配置
- `application.yml` 含 API Key,已被 `.gitignore` 排除
- MyBatis Plus 逻辑删除字段: `isDelete`,主键策略: `assign_id`(雪花算法)
- PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象,非数组)
- 向量维度: 1536,距离类型: COSINE_DISTANCE,索引: HNSW
## API 路由约定
- AI 对话: `/ai/*`(`AiController`)
- 文档上传: `/upload/*`(`DocumentController`)
- 文档管理: `/document/*`(`DocumentController`)
- 分类管理: `/category/*`(`DocumentController`)
## 已知 TODO
- `AssistantApp.doChatWithRagEnhance()`: `queryTransformers` 未生效
- `DocumentService.updateDocumentMetadata()`: Spring AI 无直接更新 vector_store metadata 的 API,向量元数据同步留后续
- `DocumentService.searchDocuments()`: Spring AI 1.0.0-M6 的 filter 支持有限,分类过滤暂未实现
- `CompressionQueryRewriter`: 当前传入空历史列表
- MyBatis Plus 3.5.12 的 `mybatis-plus-spring-boot3-starter` 不含 `PaginationInnerInterceptor`,分页通过 SQL `LIMIT/OFFSET` 手动实现

12
frontend.html

@ -723,7 +723,7 @@ async function loadCategories() {
$('categoryList').innerHTML = list.map(c => $('categoryList').innerHTML = list.map(c =>
`<div style="padding:8px 0;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;"> `<div style="padding:8px 0;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<span><strong>${c.name}</strong> <span style="color:var(--sub);font-size:12px;">${c.description||''}</span></span> <span><strong>${c.name}</strong> <span style="color:var(--sub);font-size:12px;">${c.description||''}</span></span>
<button class="btn btn-danger btn-sm" onclick="deleteCategory(${c.id})">删除</button>
<button class="btn btn-danger btn-sm" onclick="deleteCategory('${c.id}')">删除</button>
</div>` </div>`
).join(''); ).join('');
} }
@ -794,7 +794,7 @@ async function loadDocuments(page=1) {
} else { } else {
$('docTableBody').innerHTML = docs.map(d => { $('docTableBody').innerHTML = docs.map(d => {
const statusClass = d.status==='READY'?'status-ready':d.status==='PROCESSING'?'status-processing':'status-failed'; const statusClass = d.status==='READY'?'status-ready':d.status==='PROCESSING'?'status-processing':'status-failed';
const catName = categoryMap[d.categoryId] || (d.categoryId>0?'未知':'未分类');
const catName = categoryMap[d.categoryId] || (d.categoryId && d.categoryId!=='0'?'未知':'未分类');
return `<tr> return `<tr>
<td>${d.id}</td> <td>${d.id}</td>
<td><strong>${d.title}</strong><br><span style="font-size:11px;color:var(--sub);">${d.sourceName||''}</span></td> <td><strong>${d.title}</strong><br><span style="font-size:11px;color:var(--sub);">${d.sourceName||''}</span></td>
@ -803,9 +803,9 @@ async function loadDocuments(page=1) {
<td>${d.chunkCount}</td> <td>${d.chunkCount}</td>
<td>${formatDate(d.createTime)}</td> <td>${formatDate(d.createTime)}</td>
<td> <td>
<button class="btn btn-sm btn-outline" onclick="viewDocDetail(${d.id})">查看</button>
<button class="btn btn-sm btn-warn" onclick="reprocessDoc(${d.id})">重新处理</button>
<button class="btn btn-sm btn-danger" onclick="deleteDoc(${d.id})">删除</button>
<button class="btn btn-sm btn-outline" onclick="viewDocDetail('${d.id}')">查看</button>
<button class="btn btn-sm btn-warn" onclick="reprocessDoc('${d.id}')">重新处理</button>
<button class="btn btn-sm btn-danger" onclick="deleteDoc('${d.id}')">删除</button>
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
@ -844,7 +844,7 @@ async function viewDocDetail(id) {
const d = detailJson.data; const d = detailJson.data;
const chunks = chunksJson.success ? (chunksJson.data || []) : []; const chunks = chunksJson.success ? (chunksJson.data || []) : [];
const statusClass = d.status==='READY'?'status-ready':d.status==='PROCESSING'?'status-processing':'status-failed'; const statusClass = d.status==='READY'?'status-ready':d.status==='PROCESSING'?'status-processing':'status-failed';
const catName = categoryMap[d.categoryId] || (d.categoryId>0?'未知':'未分类');
const catName = categoryMap[d.categoryId] || (d.categoryId && d.categoryId!=='0'?'未知':'未分类');
let html = ` let html = `
<div style="margin-bottom:16px;"> <div style="margin-bottom:16px;">

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

@ -1,6 +1,8 @@
package com.wok.supportbot.entity; package com.wok.supportbot.entity;
import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
@ -25,6 +27,7 @@ public class KnowledgeCategory implements Serializable {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.ASSIGN_ID) @TableId(value = "id", type = IdType.ASSIGN_ID)
@JsonSerialize(using = ToStringSerializer.class)
private Long id; private Long id;
/** /**
@ -43,6 +46,7 @@ public class KnowledgeCategory implements Serializable {
* 父分类ID - 0表示顶级分类 * 父分类ID - 0表示顶级分类
*/ */
@TableField("parent_id") @TableField("parent_id")
@JsonSerialize(using = ToStringSerializer.class)
private Long parentId; private Long parentId;
/** /**

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

@ -1,6 +1,8 @@
package com.wok.supportbot.entity; package com.wok.supportbot.entity;
import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.wok.supportbot.handler.PostgresJsonTypeHandler; import com.wok.supportbot.handler.PostgresJsonTypeHandler;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@ -27,6 +29,7 @@ public class KnowledgeDocument implements Serializable {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.ASSIGN_ID) @TableId(value = "id", type = IdType.ASSIGN_ID)
@JsonSerialize(using = ToStringSerializer.class)
private Long id; private Long id;
/** /**
@ -63,6 +66,7 @@ public class KnowledgeDocument implements Serializable {
* 所属分类ID - 0表示未分类 * 所属分类ID - 0表示未分类
*/ */
@TableField("category_id") @TableField("category_id")
@JsonSerialize(using = ToStringSerializer.class)
private Long categoryId; private Long categoryId;
/** /**

12
src/main/resources/static/frontend.html

@ -723,7 +723,7 @@ async function loadCategories() {
$('categoryList').innerHTML = list.map(c => $('categoryList').innerHTML = list.map(c =>
`<div style="padding:8px 0;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;"> `<div style="padding:8px 0;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<span><strong>${c.name}</strong> <span style="color:var(--sub);font-size:12px;">${c.description||''}</span></span> <span><strong>${c.name}</strong> <span style="color:var(--sub);font-size:12px;">${c.description||''}</span></span>
<button class="btn btn-danger btn-sm" onclick="deleteCategory(${c.id})">删除</button>
<button class="btn btn-danger btn-sm" onclick="deleteCategory('${c.id}')">删除</button>
</div>` </div>`
).join(''); ).join('');
} }
@ -794,7 +794,7 @@ async function loadDocuments(page=1) {
} else { } else {
$('docTableBody').innerHTML = docs.map(d => { $('docTableBody').innerHTML = docs.map(d => {
const statusClass = d.status==='READY'?'status-ready':d.status==='PROCESSING'?'status-processing':'status-failed'; const statusClass = d.status==='READY'?'status-ready':d.status==='PROCESSING'?'status-processing':'status-failed';
const catName = categoryMap[d.categoryId] || (d.categoryId>0?'未知':'未分类');
const catName = categoryMap[d.categoryId] || (d.categoryId && d.categoryId!=='0'?'未知':'未分类');
return `<tr> return `<tr>
<td>${d.id}</td> <td>${d.id}</td>
<td><strong>${d.title}</strong><br><span style="font-size:11px;color:var(--sub);">${d.sourceName||''}</span></td> <td><strong>${d.title}</strong><br><span style="font-size:11px;color:var(--sub);">${d.sourceName||''}</span></td>
@ -803,9 +803,9 @@ async function loadDocuments(page=1) {
<td>${d.chunkCount}</td> <td>${d.chunkCount}</td>
<td>${formatDate(d.createTime)}</td> <td>${formatDate(d.createTime)}</td>
<td> <td>
<button class="btn btn-sm btn-outline" onclick="viewDocDetail(${d.id})">查看</button>
<button class="btn btn-sm btn-warn" onclick="reprocessDoc(${d.id})">重新处理</button>
<button class="btn btn-sm btn-danger" onclick="deleteDoc(${d.id})">删除</button>
<button class="btn btn-sm btn-outline" onclick="viewDocDetail('${d.id}')">查看</button>
<button class="btn btn-sm btn-warn" onclick="reprocessDoc('${d.id}')">重新处理</button>
<button class="btn btn-sm btn-danger" onclick="deleteDoc('${d.id}')">删除</button>
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
@ -844,7 +844,7 @@ async function viewDocDetail(id) {
const d = detailJson.data; const d = detailJson.data;
const chunks = chunksJson.success ? (chunksJson.data || []) : []; const chunks = chunksJson.success ? (chunksJson.data || []) : [];
const statusClass = d.status==='READY'?'status-ready':d.status==='PROCESSING'?'status-processing':'status-failed'; const statusClass = d.status==='READY'?'status-ready':d.status==='PROCESSING'?'status-processing':'status-failed';
const catName = categoryMap[d.categoryId] || (d.categoryId>0?'未知':'未分类');
const catName = categoryMap[d.categoryId] || (d.categoryId && d.categoryId!=='0'?'未知':'未分类');
let html = ` let html = `
<div style="margin-bottom:16px;"> <div style="margin-bottom:16px;">

Loading…
Cancel
Save