From 9338fcbd5fd8502688824e782c0acc1db9a7bc04 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Tue, 25 Aug 2026 11:37:18 +0800 Subject: [PATCH 01/39] =?UTF-8?q?feat(security):=20JWT=20token=5Fversion?= =?UTF-8?q?=20=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=92=A4=E9=94=80=E4=B8=8E?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 token_version 服务端撤销机制:改密/禁用/角色变更/登出时自增,使已签发 token 立即失效 - refresh token 从 localStorage 迁移到 httpOnly Cookie,access token 有效期缩短至 15 分钟 - 新增登录失败锁定(LoginAttemptService)与密码强度校验(PasswordValidator) - SDK JWT 密钥支持环境变量注入,使用默认密钥时告警 修复: - assignRoles 角色未变化仍自增 token_version,导致用户新建用户时 token 被误失效而跳转登录 - 角色 ID 解析兼容前端字符串传输,避免雪花 ID 精度丢失导致 ClassCastException - updateFill 改用 setFieldValByName,修复 update_time 未自动填充的问题 --- frontend/src/App.vue | 30 +-- frontend/src/api/auth.ts | 16 +- frontend/src/api/request.ts | 66 +++++- frontend/src/layouts/MainLayout.vue | 4 +- frontend/src/layouts/Topbar.vue | 91 +++++++- frontend/src/stores/auth.ts | 18 +- frontend/src/types/models.ts | 6 +- frontend/src/utils/sse.ts | 4 +- frontend/src/utils/token.ts | 21 +- frontend/src/views/LoginPage.vue | 5 +- .../wok/supportbot/auth/AuthController.java | 201 ++++++++++++++--- .../com/wok/supportbot/config/CorsConfig.java | 21 +- .../supportbot/config/DatabaseInitConfig.java | 51 ++++- .../controller/SysUserController.java | 32 ++- .../com/wok/supportbot/entity/SysUser.java | 4 + .../handler/MyMetaObjectHandler.java | 5 +- .../supportbot/security/JwtAuthFilter.java | 43 ++-- .../supportbot/security/JwtTokenProvider.java | 81 +++++-- .../security/LoginAttemptService.java | 95 ++++++++ .../security/PasswordValidator.java | 48 ++++ .../supportbot/security/SdkAuthFilter.java | 39 +++- .../security/SdkJwtTokenProvider.java | 18 +- .../supportbot/security/SecurityConfig.java | 17 +- .../supportbot/service/SysUserService.java | 63 +++++- src/main/resources/application-dev.yml | 15 +- src/main/resources/application-prod.yml | 16 +- src/main/resources/application.yml | 28 ++- src/main/resources/init-database.sql | 210 ++++++++++++++++-- src/main/resources/static/sdk/test.html | 8 +- src/main/resources/support-bot.sql | 16 +- 30 files changed, 1067 insertions(+), 205 deletions(-) create mode 100644 src/main/java/com/wok/supportbot/security/LoginAttemptService.java create mode 100644 src/main/java/com/wok/supportbot/security/PasswordValidator.java diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3679acc..2c75068 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,11 +6,11 @@ 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/request.ts b/frontend/src/api/request.ts index d38101d..49d938e 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,64 @@ 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')) { + // 已有刷新在进行中,挂起当前请求,等待新 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 || '网络错误')) + } + }) + }) + } + + // 发起刷新(request 的 url 为 /auth/refresh,其 401 会被上方判断跳过,不会递归) + isRefreshing = true + try { + const res = await request.post('/auth/refresh') + const newToken = res.data?.data?.accessToken + if (newToken) { + setToken(newToken) + // 放行所有挂起请求(请求拦截器会自动附加新 token) + pendingQueue.forEach((cb) => cb(newToken)) + pendingQueue = [] + // 重试当前请求 + 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/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 }} + 改密 退出 + + + + + + + + + + + + + + From d5d392aca4bc24ea2f5d7450846eeb2f65572047 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Tue, 25 Aug 2026 17:51:56 +0800 Subject: [PATCH 03/39] =?UTF-8?q?feat(sdk):=20=E6=94=AF=E6=8C=81=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E4=B8=8A=E4=BC=A0=E4=B8=8E=E5=A4=9A=E6=A8=A1=E6=80=81?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDK 前端新增图片附件上传与多模态对话能力,输入区新增附件按钮与图片预览,新增 allowImageUpload 配置项;/attachment/upload 鉴权改由 SdkAuthFilter 承担(移除方法级 @PreAuthorize),JwtAuthFilter/SecurityConfig 同步放行 --- client/src/api.ts | 68 ++++++++-- client/src/chat.ts | 120 +++++++++++++++--- client/src/config.ts | 1 + client/src/dom.ts | 28 +++- client/src/i18n.ts | 2 + client/src/index.ts | 1 + client/src/types.ts | 20 +++ .../controller/AttachmentController.java | 6 +- .../supportbot/security/JwtAuthFilter.java | 4 +- .../supportbot/security/SdkAuthFilter.java | 4 +- .../supportbot/security/SecurityConfig.java | 2 + src/main/resources/static/sdk/test.html | 2 +- 12 files changed, 221 insertions(+), 37 deletions(-) diff --git a/client/src/api.ts b/client/src/api.ts index 1232d60..6971906 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -6,7 +6,7 @@ * userId → accountId(客户账号 ID) * chatId → 自动管理的对话 ID(从 /ai/sdk/conversation/list 获取或自动生成) */ -import { ResolvedConfig, ApiResponse, CategoryNode } from './types'; +import { ResolvedConfig, ApiResponse, CategoryNode, ImageAttachment } from './types'; import { logger } from './logger'; import { t } from './i18n'; @@ -72,13 +72,22 @@ function setIfPresent(params: URLSearchParams, key: string, value: string | numb // ==================== 对话接口 URL 构建 ==================== +/** + * 把图片 URL 列表追加为逗号分隔、encodeURIComponent 编码的查询参数 + */ +function appendImageUrls(params: URLSearchParams, imageUrls?: string[]): void { + if (imageUrls && imageUrls.length > 0) { + params.set('imageUrls', imageUrls.map(u => encodeURIComponent(u)).join(',')); + } +} + /** * 构建同步对话请求 URL * - integrateId → roleId * - userId → accountId * - chatId → 自动管理的对话 ID */ -function buildChatUrl(message: string): string { +function buildChatUrl(message: string, imageUrls?: string[]): string { const params = new URLSearchParams(); params.set('message', message); params.set('chatId', currentConfig!.chatId); @@ -87,6 +96,7 @@ function buildChatUrl(message: string): string { setIfPresent(params, 'roleId', getActiveIntegrateId()); // userId 映射为 accountId setIfPresent(params, 'accountId', currentConfig!.userId); + appendImageUrls(params, imageUrls); return buildUrl(`/ai/chat?${params.toString()}`); } @@ -94,7 +104,7 @@ function buildChatUrl(message: string): string { /** * 构建 SSE 流式请求 URL */ -function buildChatSSEUrl(message: string, categoryId?: number, useRag?: boolean): string { +function buildChatSSEUrl(message: string, categoryId?: number, useRag?: boolean, imageUrls?: string[]): string { const params = new URLSearchParams(); params.set('message', message); params.set('chatId', currentConfig!.chatId); @@ -108,6 +118,7 @@ function buildChatSSEUrl(message: string, categoryId?: number, useRag?: boolean) params.set('enableRag', 'true'); params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE'); } + appendImageUrls(params, imageUrls); return buildUrl(`/ai/chat/stream?${params.toString()}`); } @@ -161,8 +172,9 @@ async function safeFetch( Object.assign(headers, options.headers as Record); } } - // 为受 SdkAuthFilter 保护的路径自动注入 Bearer Token(/ai/** 和 /feedback) - if (currentConfig?.token && (url.includes('/ai/') || url.endsWith('/feedback'))) { + // 为受 SdkAuthFilter 保护的路径自动注入 Bearer Token(/ai/**、/feedback、/attachment/upload) + if (currentConfig?.token && + (url.includes('/ai/') || url.endsWith('/feedback') || url.includes('/attachment/upload'))) { headers['Authorization'] = `Bearer ${currentConfig.token}`; } @@ -223,8 +235,8 @@ function getHttpErrorMessage(status: number): string { /** * 同步对话请求 */ -export async function chatRequest(message: string): Promise { - const url = buildChatUrl(message); +export async function chatRequest(message: string, imageUrls?: string[]): Promise { + const url = buildChatUrl(message, imageUrls); logger.lifecycleSend(getActiveIntegrateId(), message.length); try { @@ -257,11 +269,12 @@ export async function chatSSERequest( onError: (error: CskError) => void, categoryId?: number, useRag?: boolean, + imageUrls?: string[], signal?: AbortSignal ): Promise { const url = useRag - ? buildChatSSEUrl(message, categoryId, true) - : buildChatSSEUrl(message, categoryId, false); + ? buildChatSSEUrl(message, categoryId, true, imageUrls) + : buildChatSSEUrl(message, categoryId, false, imageUrls); let totalText = ''; /** @@ -449,6 +462,43 @@ export async function chatSSERequest( } } +// ==================== 图片上传 ==================== + +/** 上传超时时间(图片体积较大,放宽到 120s) */ +const UPLOAD_TIMEOUT = 120000; + +/** + * 上传单个图片到 /attachment/upload,返回可公开访问的 URL 结构 + */ +export async function uploadAttachment(file: File): Promise { + const url = buildUrl('/attachment/upload'); + const fd = new FormData(); + fd.append('file', file); + + const response = await safeFetch(url, { method: 'POST', body: fd }, UPLOAD_TIMEOUT); + if (!response.ok) { + throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`); + } + + const json = await response.json() as ApiResponse<{ + url: string; + name?: string; + mimeType?: string; + size?: number; + }>; + + if (!json.success || !json.data?.url) { + throw new CskError(json.message || t('error_upload_failed'), 'upload_failed'); + } + + return { + name: json.data.name || file.name, + url: json.data.url, + mimeType: json.data.mimeType, + size: json.data.size, + }; +} + // ==================== P1: 知识库分类 ==================== /** diff --git a/client/src/chat.ts b/client/src/chat.ts index d6882d2..1e39fc7 100644 --- a/client/src/chat.ts +++ b/client/src/chat.ts @@ -6,7 +6,7 @@ * userId → accountId(客户账号 ID) * chatId → 自动管理(从 /conversation/list 获取或自动生成) */ -import { ResolvedConfig, ChatMessage, RagSource } from './types'; +import { ResolvedConfig, ChatMessage, RagSource, ImageAttachment } from './types'; import { chatRequest, chatSSERequest, @@ -26,6 +26,7 @@ import { getActiveIntegrateId, CskError, fetchSuggestions, + uploadAttachment, } from './api'; import { renderUserBubble, @@ -83,6 +84,9 @@ let currentCategoryId: number | undefined; /** 当前是否使用 RAG 对话 */ let useRag = false; +/** 待发送的图片列表(与 t-chat-sender 的 attachmentsProps.items 同步) */ +let pendingImages: ImageAttachment[] = []; + /** * 初始化对话模块 */ @@ -195,12 +199,20 @@ async function loadHistoryFromBackend(): Promise { function bindSendEvents(): void { if (!inputEl) return; - // t-chat-sender:send 事件(点击发送/回车)携带 value;stop 事件(loading 态点击)中断流式 + // t-chat-sender:send 事件(点击发送/回车)携带 value 与 attachments;stop 事件(loading 态点击)中断流式 inputEl.addEventListener('send', (e) => { - const detail = (e as CustomEvent).detail as { value?: string } | undefined; + const detail = (e as CustomEvent).detail as { + value?: string; + attachments?: Array<{ name?: string; url?: string; fileType?: string; size?: number }>; + } | undefined; const value = (detail?.value || '').trim(); - if (!value || isSending) return; - handleSend(value); + // 从附件中提取图片(本次仅图片参与多模态对话) + const images: ImageAttachment[] = (detail?.attachments || []) + .filter(a => a.fileType === 'image' && a.url) + .map(a => ({ name: a.name || '', url: a.url as string, size: a.size })); + + if ((!value && images.length === 0) || isSending) return; + handleSend(value, images); }); inputEl.addEventListener('stop', () => { @@ -209,11 +221,61 @@ function bindSendEvents(): void { } }); + // t-chat-sender:fileSelect 事件(选择文件后触发,携带原始 File[]) + inputEl.addEventListener('fileSelect', (e) => { + const files = (e as CustomEvent).detail; + if (!files || files.length === 0) return; + handleFileSelect(files); + }); + if (clearBtn) { clearBtn.addEventListener('click', () => handleClear()); } } +/** + * 处理文件选择:逐个上传到 /attachment/upload,成功后同步到输入区附件预览 + */ +async function handleFileSelect(files: File[]): Promise { + if (!config) return; + + for (const file of files) { + try { + const att = await uploadAttachment(file); + pendingImages.push(att); + } catch (err) { + const msg = err instanceof CskError ? err.message : t('error_upload_failed'); + if (messagesContainer) renderErrorBubble(messagesContainer, msg, now()); + logger.error('图片上传失败', err); + } + } + + syncSenderAttachments(); +} + +/** + * 把 pendingImages 同步到 t-chat-sender 的 attachmentsProps.items, + * 由 TDesign Chat 组件原生渲染附件预览。 + */ +function syncSenderAttachments(): void { + if (!inputEl) return; + const items = pendingImages.map(img => ({ + name: img.name, + url: img.url, + fileType: 'image' as const, + size: img.size, + })); + (inputEl as unknown as { + attachmentsProps: { items: typeof items; overflow: string }; + }).attachmentsProps = { items, overflow: 'scrollX' }; +} + +/** 清空待发送图片(发送成功后、清空会话、切换角色/会话时调用) */ +function clearPendingImages(): void { + pendingImages = []; + syncSenderAttachments(); +} + /** 绑定滚动监听:判断是否在底部,控制新消息提示按钮 */ function bindScrollEvents(): void { if (!messagesContainer) return; @@ -404,16 +466,20 @@ function updateEmptyState(): void { } /** 处理发送消息(text 来自 t-chat-sender 的 send 事件,或快捷问题/重试的显式调用) */ -async function handleSend(text?: string): Promise { +async function handleSend(text?: string, images?: ImageAttachment[]): Promise { if (!config || isSending) return; const input = (text ?? '').trim(); - if (input === '') return; + const atts = images && images.length > 0 ? images : [...pendingImages]; + if (input === '' && atts.length === 0) return; + + // 清空待发送图片(快照已保存到 atts,发送失败时恢复) + clearPendingImages(); // 1. 渲染用户气泡 const userTimestamp = now(); - const userMsg: ChatMessage = { id: uuid(), role: 'user', content: input, timestamp: userTimestamp }; - if (messagesContainer) renderUserBubble(messagesContainer, input, userTimestamp); + const userMsg: ChatMessage = { id: uuid(), role: 'user', content: input, timestamp: userTimestamp, images: atts }; + if (messagesContainer) renderUserBubble(messagesContainer, input, userTimestamp, atts); messages.push(userMsg); updateEmptyState(); @@ -421,15 +487,21 @@ async function handleSend(text?: string): Promise { if (messagesContainer) smartScrollToBottom(); // 2. 生成 AI 回复 - await produceAIReply(input); + const ok = await produceAIReply(input, atts); + // 发送失败时恢复待发送图片,方便用户重试 + if (!ok && atts.length > 0) { + pendingImages = atts; + syncSenderAttachments(); + } } /** * 生成 AI 回复(发送请求 + 渲染气泡 + 持久化) * 复用于:正常发送、重试。调用方负责先渲染用户气泡。 + * @returns 是否发送成功(false 时调用方可恢复待发送图片) */ -async function produceAIReply(userText: string): Promise { - if (!config || !messagesContainer) return; +async function produceAIReply(userText: string, images?: ImageAttachment[]): Promise { + if (!config || !messagesContainer) return false; isSending = true; setSendButtonMode('stop'); @@ -439,6 +511,9 @@ async function produceAIReply(userText: string): Promise { await initChatId(); } + // 提取图片 URL,仅图片参与多模态对话 + const imageUrls = (images || []).map(img => img.url); + const aiTimestamp = now(); // RAG 启用条件:由 enableRag 控制 const shouldUseRag = useRag; @@ -452,9 +527,9 @@ async function produceAIReply(userText: string): Promise { try { if (config.streaming) { - aiContent = await sendStreamMessage(userText, aiTimestamp, shouldUseRag, aiMsgId); + aiContent = await sendStreamMessage(userText, aiTimestamp, shouldUseRag, aiMsgId, imageUrls); } else { - aiContent = await chatRequest(userText); + aiContent = await chatRequest(userText, imageUrls); if (hideLoadingFn) hideLoadingFn(); if (messagesContainer) { renderAIBubble(messagesContainer, aiContent, aiTimestamp, aiMsgId); @@ -484,6 +559,7 @@ async function produceAIReply(userText: string): Promise { // 发送成功后清除离线横幅(网络已恢复) hideOfflineBanner(); + return true; } catch (err) { if (hideLoadingFn) hideLoadingFn(); @@ -492,6 +568,7 @@ async function produceAIReply(userText: string): Promise { renderErrorBubble(messagesContainer, errMsg, now()); } logger.error(`发送失败 integrateId=${config.integrateId}`, err); + return false; } finally { isSending = false; abortController = null; @@ -500,7 +577,7 @@ async function produceAIReply(userText: string): Promise { } /** 流式发送消息 */ -async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag: boolean, aiMsgId: string): Promise { +async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag: boolean, aiMsgId: string, imageUrls?: string[]): Promise { // 创建中断控制器,供"停止生成"使用 abortController = new AbortController(); const signal = abortController.signal; @@ -535,7 +612,7 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag // 无流内容降级为同步请求(须在 wrapperEl/bubbleEl 判断之外: // 二者仅在 onChunk 收到首个 token 时才赋值,否则此分支不可达) if (!streamStarted && accumulated === '') { - chatRequest(text).then(resolve).catch(reject); + chatRequest(text, imageUrls).then(resolve).catch(reject); return; } if (wrapperEl && bubbleEl) { @@ -563,6 +640,7 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag }, currentCategoryId, shouldUseRag, + imageUrls, signal ); }); @@ -597,7 +675,8 @@ export async function retryFromMessage(msgId: string): Promise { } if (userIndex < 0) return; const userText = messages[userIndex].content; - if (!userText) return; + const userImages = messages[userIndex].images || []; + if (!userText && userImages.length === 0) return; // 计算 userTurn(1-based,仅统计 USER 消息):该轮提问是第几条用户消息 let userTurn = 0; @@ -623,7 +702,7 @@ export async function retryFromMessage(msgId: string): Promise { renderHistory(); // 4. 重新发送该轮提问(重新渲染用户气泡 + 生成新回复) - await handleSend(userText); + await handleSend(userText, userImages); } finally { isRetrying = false; } @@ -694,7 +773,7 @@ function renderHistory(): void { for (const msg of messages) { if (msg.role === 'user') { - renderUserBubble(messagesContainer, msg.content, msg.timestamp); + renderUserBubble(messagesContainer, msg.content, msg.timestamp, msg.images); } else { const wrapper = renderAIBubble(messagesContainer, msg.content, msg.timestamp, msg.id, msg.feedback); if (msg.sources && msg.sources.length > 0) renderSources(wrapper, msg.sources); @@ -731,6 +810,7 @@ function handleClear(): void { if (clearBtn) clearBtn.style.display = 'none'; updateEmptyState(); clearMessages(config.integrateId); + clearPendingImages(); // 生成新的 chatId,开始新会话 const newId = generateNewChatId(); @@ -789,6 +869,7 @@ export async function switchRole(newRoleId: string): Promise { messages = []; const msgNodes = messagesContainer.querySelectorAll('.csk-msg, .csk-loading'); msgNodes.forEach(el => el.remove()); + clearPendingImages(); // 5. 清空新旧角色的 localStorage 缓存(消息 + chatId),必须在 setActiveRoleId 之前 clearMessages(oldRoleId); @@ -928,6 +1009,7 @@ async function switchToConversation(conversationId: string): Promise { messages = []; const msgs = messagesContainer.querySelectorAll('.csk-msg, .csk-loading'); msgs.forEach(el => el.remove()); + clearPendingImages(); // 4. 从后端加载该会话的消息 try { diff --git a/client/src/config.ts b/client/src/config.ts index 76993d6..bdbfbd2 100644 --- a/client/src/config.ts +++ b/client/src/config.ts @@ -87,6 +87,7 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null { rewriteStrategy: raw.rewriteStrategy || 'REWRITE', locale: raw.locale || 'zh-CN', debug: raw.debug ?? true, + allowImageUpload: raw.allowImageUpload ?? true, sound: raw.sound ?? false, notification: raw.notification ?? false, onError: typeof raw.onError === 'function' ? raw.onError : undefined, diff --git a/client/src/dom.ts b/client/src/dom.ts index 5a4f710..5a73cc2 100644 --- a/client/src/dom.ts +++ b/client/src/dom.ts @@ -7,7 +7,7 @@ * - 无消息时显示欢迎空状态 * - 输入区采用圆角容器包裹文本框与发送按钮 */ -import { ResolvedConfig, RagSource } from './types'; +import { ResolvedConfig, RagSource, ImageAttachment } from './types'; import { debounce, formatTime, formatHistoryTime } from './utils'; import { t } from './i18n'; @@ -352,6 +352,8 @@ export function createChatWindow(config: ResolvedConfig): { inputEl.setAttribute('placeholder', t('placeholder')); // 压缩输入框:autosize 为 Object 复杂类型,走 property 赋值(同 dom.ts createChatItemMsg 的 content 处理) (inputEl as unknown as { autosize: { minRows: number } }).autosize = { minRows: 1 }; + // 启用附件按钮 + 发送按钮(actions 为 Array 复杂类型,走 property 赋值) + (inputEl as unknown as { actions: string[] }).actions = config.allowImageUpload ? ['attachment', 'send'] : ['send']; inputArea.appendChild(inputEl); // === 保密声明脚注(默认折叠为单行,点击展开完整条文) === @@ -823,11 +825,31 @@ function createChatItemMsg(role: 'user' | 'assistant', content: unknown[], times return msg; } -export function renderUserBubble(container: HTMLElement, text: string, timestamp: number): HTMLElement { +export function renderUserBubble( + container: HTMLElement, + text: string, + timestamp: number, + images?: ImageAttachment[] +): HTMLElement { const wrapper = document.createElement('div'); wrapper.className = 'csk-msg csk-msg--user'; - wrapper.appendChild(createChatItemMsg('user', [{ type: 'text', data: text }], timestamp)); + // 组装 content:文本 + 附件(TDesign Chat 的 attachment content 类型,复用组件原生渲染) + const content: Array<{ type: string; data: unknown }> = []; + if (text) content.push({ type: 'text', data: text }); + if (images && images.length > 0) { + content.push({ + type: 'attachment', + data: images.map(img => ({ + name: img.name, + url: img.url, + fileType: 'image', + size: img.size, + })), + }); + } + + wrapper.appendChild(createChatItemMsg('user', content, timestamp)); container.appendChild(wrapper); diff --git a/client/src/i18n.ts b/client/src/i18n.ts index 8145b35..682c8cd 100644 --- a/client/src/i18n.ts +++ b/client/src/i18n.ts @@ -74,6 +74,7 @@ const dictionaries: Record> = { error_unavailable: '服务暂不可用,请稍后重试', error_unknown: '请求发生未知错误', error_send: '发送失败,请稍后重试', + error_upload_failed: '图片上传失败,请稍后重试', error_stream_unsupported: '浏览器不支持流式读取', }, @@ -147,6 +148,7 @@ const dictionaries: Record> = { error_unavailable: 'Service temporarily unavailable', error_unknown: 'Unknown request error', error_send: 'Failed to send, please try again', + error_upload_failed: 'Image upload failed, please try again', error_stream_unsupported: 'Browser does not support streaming', }, }; diff --git a/client/src/index.ts b/client/src/index.ts index 2116a4d..7cb9732 100644 --- a/client/src/index.ts +++ b/client/src/index.ts @@ -28,6 +28,7 @@ export type { SDKConfig, ResolvedConfig, ChatMessage, + ImageAttachment, RagSource, CategoryNode, ChatbotSDKInstance, diff --git a/client/src/types.ts b/client/src/types.ts index 43cc94c..6fc6d1f 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -83,6 +83,10 @@ export interface SDKConfig { /** 是否输出调试日志,默认 true */ debug?: boolean; + // === 图片上传配置 === + /** 是否允许上传图片参与对话,默认 true */ + allowImageUpload?: boolean; + // === 通知配置 === /** 弹窗关闭时收到新消息是否播放提示音,默认 false */ sound?: boolean; @@ -168,6 +172,20 @@ export interface ResolvedConfig { onMessage?: (msg: ChatMessage) => void; /** 当前对话 ID(自动管理,从 /conversation/list 获取或自动生成) */ chatId: string; + /** 是否允许上传图片参与对话 */ + allowImageUpload: boolean; +} + +/** 图片附件 */ +export interface ImageAttachment { + /** 文件名 */ + name: string; + /** 可访问的 URL */ + url: string; + /** MIME 类型 */ + mimeType?: string; + /** 文件大小(字节) */ + size?: number; } /** 聊天消息 */ @@ -180,6 +198,8 @@ export interface ChatMessage { content: string; /** 时间戳(毫秒) */ timestamp: number; + /** 可选:用户消息携带的图片 */ + images?: ImageAttachment[]; /** 可选:RAG 引用来源 */ sources?: RagSource[]; /** 可选:用户反馈 'up' | 'down',预留后端对接位 */ diff --git a/src/main/java/com/wok/supportbot/controller/AttachmentController.java b/src/main/java/com/wok/supportbot/controller/AttachmentController.java index 2b09ce1..fa8a21c 100644 --- a/src/main/java/com/wok/supportbot/controller/AttachmentController.java +++ b/src/main/java/com/wok/supportbot/controller/AttachmentController.java @@ -3,7 +3,6 @@ package com.wok.supportbot.controller; import com.wok.supportbot.config.StorageProperties; import com.wok.supportbot.service.SftpStorageService; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -35,11 +34,14 @@ public class AttachmentController { /** * 上传图片/附件 * + *

鉴权由 {@code SdkAuthFilter} 承担(拦截 /attachment/upload,验证 SDK JWT 或管理后台 JWT), + * 与 /ai/** 接口一致,方法层不再叠加 @PreAuthorize(否则 SDK Token 会被 JwtAuthFilter 用管理后台 + * 密钥重复验证失败,导致 isAuthenticated() 判定为未认证)。 + * * @param file 上传的文件 * @return 上传结果:data 内含 url / name / type / mimeType / size */ @PostMapping("/upload") - @PreAuthorize("isAuthenticated()") public ResponseEntity> upload(@RequestParam("file") MultipartFile file) { try { validateFile(file); diff --git a/src/main/java/com/wok/supportbot/security/JwtAuthFilter.java b/src/main/java/com/wok/supportbot/security/JwtAuthFilter.java index e97007c..d9b2f0a 100644 --- a/src/main/java/com/wok/supportbot/security/JwtAuthFilter.java +++ b/src/main/java/com/wok/supportbot/security/JwtAuthFilter.java @@ -90,7 +90,9 @@ public class JwtAuthFilter extends OncePerRequestFilter { || path.startsWith("/assets/") || path.startsWith("/sdk/") || path.equals("/favicon.ico") - || path.equals("/favicon.svg"); + || path.equals("/favicon.svg") + || path.equals("/feedback") + || path.equals("/attachment/upload"); } /** diff --git a/src/main/java/com/wok/supportbot/security/SdkAuthFilter.java b/src/main/java/com/wok/supportbot/security/SdkAuthFilter.java index e25a934..ed85192 100644 --- a/src/main/java/com/wok/supportbot/security/SdkAuthFilter.java +++ b/src/main/java/com/wok/supportbot/security/SdkAuthFilter.java @@ -71,8 +71,8 @@ public class SdkAuthFilter extends OncePerRequestFilter { if (path.startsWith("/ai/system-config/")) { return true; } - // 拦截 /ai/ 路径和 /feedback 端点,排除静态资源和 swagger - return !path.startsWith("/ai/") && !path.equals("/feedback"); + // 拦截 /ai/ 路径、/feedback 端点,以及 SDK 上传接口 /attachment/upload,排除静态资源和 swagger + return !path.startsWith("/ai/") && !path.equals("/feedback") && !path.equals("/attachment/upload"); } @Override diff --git a/src/main/java/com/wok/supportbot/security/SecurityConfig.java b/src/main/java/com/wok/supportbot/security/SecurityConfig.java index ee8435e..eb018cb 100644 --- a/src/main/java/com/wok/supportbot/security/SecurityConfig.java +++ b/src/main/java/com/wok/supportbot/security/SecurityConfig.java @@ -67,6 +67,8 @@ public class SecurityConfig { .requestMatchers("/auth/login", "/auth/refresh", "/auth/logout").permitAll() // SDK 需要的接口(feedback、分类、会话查询等) .requestMatchers("/feedback").permitAll() + // 图片/附件上传:实际鉴权由 SdkAuthFilter 承担(与 /ai/** 一致) + .requestMatchers("/attachment/upload").permitAll() .requestMatchers("/category/tree", "/category/list").permitAll() // 系统配置(SDK 拉取声明内容) .requestMatchers("/ai/system-config/**").permitAll() diff --git a/src/main/resources/static/sdk/test.html b/src/main/resources/static/sdk/test.html index 3c5a2ea..e590a94 100644 --- a/src/main/resources/static/sdk/test.html +++ b/src/main/resources/static/sdk/test.html @@ -11,8 +11,8 @@ - + From 0aa11a5ec6dc526a5fbc9b6537e3f7b6f74b7efa Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Tue, 25 Aug 2026 17:52:09 +0800 Subject: [PATCH 04/39] =?UTF-8?q?fix(app):=20=E4=BF=AE=E5=A4=8D=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=E9=97=AE=E9=A2=98=E6=98=BE=E7=A4=BA=E5=93=A8=E5=85=B5?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SuggestionResponseParser 剥离旧版 ___SUGGESTIONS___ 哨兵标记,避免按行降级解析成垃圾条目;同步更新 suggestion_prompt 种子默认值,移除与新架构"只输出 JSON 数组"要求矛盾的哨兵文案 --- .../supportbot/app/SuggestionResponseParser.java | 9 +++++++++ .../wok/supportbot/config/DatabaseInitConfig.java | 14 +++----------- src/main/resources/init-database.sql | 12 +----------- 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java b/src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java index c9f4f01..dccae92 100644 --- a/src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java +++ b/src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java @@ -194,6 +194,15 @@ public final class SuggestionResponseParser { // 1. 尝试标准 JSON 解析 String trimmed = jsonPart.strip(); + // 剥离旧版哨兵标记(___SUGGESTIONS___ 及兼容大写变形),取哨兵后的 JSON 部分。 + // 旧版 suggestion_prompt 会要求模型输出哨兵,若未剥离会被按行降级解析成垃圾条目。 + int sentinelIdx = findSentinelIndex(trimmed); + if (sentinelIdx >= 0) { + int matchedLen = trimmed.startsWith(SENTINEL, sentinelIdx) + ? SENTINEL.length() + : LOOSE_SENTINEL.length(); + trimmed = trimmed.substring(sentinelIdx + matchedLen).strip(); + } // 去掉可能的 markdown 代码块包裹 trimmed = trimCodeBlock(trimmed); diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index 2cdde31..2f9122a 100644 --- a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java +++ b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java @@ -1384,18 +1384,10 @@ public class DatabaseInitConfig { """, "ai_system_prompt", "", "AI 对话全局系统提示词(为空则不注入;修改后即时生效,无需重启)"); // AI 推荐问题(suggest-message-list)种子数据 + // 格式要求由 SuggestionGenerator.buildPrompt 统一追加,此处仅提供基础文案, + // 不再要求模型输出 ___SUGGESTIONS___ 哨兵标记(避免与新架构"只输出 JSON 数组"要求矛盾)。 String defaultSuggestionPrompt = """ - 【推荐问题生成规则】 - 在回答正文结束后,请严格按以下格式生成 3 条用户可能继续追问的推荐问题: - - ___SUGGESTIONS___ - ["推荐问题1", "推荐问题2", "推荐问题3"] - - 要求: - 1. 推荐问题需与当前回答内容相关,覆盖用户可能关心的不同方面 - 2. 推荐问题是用户可直接点击发送的完整问题文本 - 3. 必须是合法的 JSON 字符串数组 - 4. ___SUGGESTIONS___ 是分隔标记,不要在回答正文中出现此标记 + 请根据历史对话,生成 3 条用户最可能继续提问的推荐问题。 """; jdbcTemplate.update(""" diff --git a/src/main/resources/init-database.sql b/src/main/resources/init-database.sql index 9a9fc2f..5ff61be 100644 --- a/src/main/resources/init-database.sql +++ b/src/main/resources/init-database.sql @@ -803,17 +803,7 @@ ON CONFLICT (config_key) DO NOTHING; INSERT INTO system_config (config_key, config_value, description) VALUES ('suggestion_prompt', - '【推荐问题生成规则】 -在回答正文结束后,请严格按以下格式生成 3 条用户可能继续追问的推荐问题: - -___SUGGESTIONS___ -["推荐问题1", "推荐问题2", "推荐问题3"] - -要求: -1. 推荐问题需与当前回答内容相关,覆盖用户可能关心的不同方面 -2. 推荐问题是用户可直接点击发送的完整问题文本 -3. 必须是合法的 JSON 字符串数组 -4. ___SUGGESTIONS___ 是分隔标记,不要在回答正文中出现此标记', + '请根据历史对话,生成 3 条用户最可能继续提问的推荐问题。', 'AI 推荐问题 Prompt 模板(仅在 suggestion_enabled=true 时生效)') ON CONFLICT (config_key) DO NOTHING; From 377aad48db33ecdaa74c78695e844e3c193f4c5f Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Wed, 26 Aug 2026 13:36:24 +0800 Subject: [PATCH 05/39] =?UTF-8?q?fix(app):=20=E4=BF=AE=E5=A4=8D=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E6=8E=A8=E7=90=86=E6=A8=A1=E5=9E=8B=E5=90=8E=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E6=B5=81=E5=BC=8F=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chatStreamOpenAi 流开始时立即发出首片 chunk(role=assistant),确保 SSE 响应头及时 flush,避免推理模型思考阶段无输出导致前端等待首字节触发 60s 超时;AI_READ_TIMEOUT 由 120s 放宽到 300s,给推理模型思考+生成留足总时长 --- .../java/com/wok/supportbot/app/AssistantApp.java | 13 +++++++------ .../com/wok/supportbot/config/ChatModelFactory.java | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/wok/supportbot/app/AssistantApp.java b/src/main/java/com/wok/supportbot/app/AssistantApp.java index faa297c..a3a4010 100644 --- a/src/main/java/com/wok/supportbot/app/AssistantApp.java +++ b/src/main/java/com/wok/supportbot/app/AssistantApp.java @@ -44,7 +44,6 @@ import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -519,11 +518,9 @@ public class AssistantApp { }); // 聚合所有分片用于埋点(在 doFinally 时取完整回复文本) StringBuilder aggregated = new StringBuilder(); - // 首片标记:第一片 delta 需带 role=assistant,后续片仅含 content - AtomicBoolean first = new AtomicBoolean(true); return preserveTrailingWhitespace(rawStream) .doOnNext(aggregated::append) - .map(chunk -> buildOpenAiChunk(completionId, model, created, chunk, first.getAndSet(false), null)) + .map(chunk -> buildOpenAiChunk(completionId, model, created, chunk, false, null)) .doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY)) .doOnError(e -> { aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY); @@ -543,13 +540,17 @@ public class AssistantApp { usage != null ? usage.getTotalTokens() : null, events)); }) + // 首片(仅 role=assistant、无 content)在流订阅时立即发出,确保 SSE 响应头/首字节及时 flush。 + // 推理模型(如 doubao-seed)思考阶段 delta.content 为空、被 preserveTrailingWhitespace 吞掉, + // 若不提前发首片,思考阶段将无任何字节输出,前端等待首字节会触发 60s 超时。 + .startWith(buildOpenAiChunk(completionId, model, created, "", true, null)) // 流正常结束时追加 finish_reason=stop 的 chunk 与 [DONE] .concatWith(Flux.just( buildOpenAiChunk(completionId, model, created, "", false, "stop"), "[DONE]")) - // 错误兜底:脱敏错误信息,避免泄露内部细节;role 是否出现取决于此前是否已发出过内容片 + // 错误兜底:脱敏错误信息,避免泄露内部细节(首片 role 已提前发出,此处不再带 role) .onErrorResume(e -> openAiFallbackStream(completionId, model, created, - "抱歉,AI 服务调用失败:" + maskError(e.getMessage()), first.get())); + "抱歉,AI 服务调用失败:" + maskError(e.getMessage()), false)); } /** diff --git a/src/main/java/com/wok/supportbot/config/ChatModelFactory.java b/src/main/java/com/wok/supportbot/config/ChatModelFactory.java index 124c6de..15438cc 100644 --- a/src/main/java/com/wok/supportbot/config/ChatModelFactory.java +++ b/src/main/java/com/wok/supportbot/config/ChatModelFactory.java @@ -66,8 +66,8 @@ public class ChatModelFactory { /** AI 模型调用连接超时 */ private static final Duration AI_CONNECT_TIMEOUT = Duration.ofSeconds(10); - /** AI 模型调用读取超时,含流式生成时间 */ - private static final Duration AI_READ_TIMEOUT = Duration.ofSeconds(120); + /** AI 模型调用读取超时,含流式生成时间(推理模型思考+生成耗时较长,放宽到 300s) */ + private static final Duration AI_READ_TIMEOUT = Duration.ofSeconds(300); /** * 构建带超时配置的 RestClient.Builder,供 DashScope 和 OpenAI 兼容路径共用。 From 081758128c34cc0edff61b41aa7101194aad808a Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Wed, 26 Aug 2026 15:05:15 +0800 Subject: [PATCH 06/39] =?UTF-8?q?fix(app):=20=E4=BF=AE=E5=A4=8D=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E4=BC=9A=E8=AF=9D=E5=90=8E=E7=94=A8=E6=88=B7=E6=8F=90?= =?UTF-8?q?=E9=97=AE=E8=A2=AB=E6=9F=A5=E8=AF=A2=E9=87=8D=E5=86=99=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RAG 查询重写(REWRITE 等策略)的润色产物被当作用户消息落库到 chat_message 会话记忆表,导致 ChatSDK 切换角色/对话后前端拉取 历史显示的是润色版而非原始提问。 ChatPipeline 在 RAG 分支将 finalMessage 由 rag.rewrittenQuery() 改为 ctx.message(),使查询重写仅服务于检索,用户消息(传给模型、 落库、前端展示)统一为原始提问。 --- src/main/java/com/wok/supportbot/app/ChatPipeline.java | 6 +++--- src/main/java/com/wok/supportbot/app/ChatRequest.java | 2 +- src/main/java/com/wok/supportbot/rag/RagContext.java | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/wok/supportbot/app/ChatPipeline.java b/src/main/java/com/wok/supportbot/app/ChatPipeline.java index cfdca77..9c7b792 100644 --- a/src/main/java/com/wok/supportbot/app/ChatPipeline.java +++ b/src/main/java/com/wok/supportbot/app/ChatPipeline.java @@ -66,7 +66,7 @@ public class ChatPipeline { *

  • 未启用 RAG(普通对话 / 严格隔离下 KB 拒绝)→ 用原始 message、基础 system
  • *
  • 寒暄/闲聊(IntentRouter 或寒暄词命中)→ 同上,跳过 KB 检索
  • *
  • FAQ 命中 → 直接返回标准答案,不调用 ChatClient
  • - *
  • RAG 生成 → 资料块注入 system,重写后查询作为 user 消息
  • + *
  • RAG 生成 → 资料块注入 system,原始 message 作为 user 消息(重写查询仅用于检索)
  • * * * @param ctx 对话上下文 @@ -128,9 +128,9 @@ public class ChatPipeline { globalPrompt, null, null, "FAQ", null, null, rag.faqMatchResult()); } - // RAG 生成:资料块注入 system,重写后查询作为 user 消息 + // RAG 生成:资料块注入 system,原始 message 作为 user 消息(重写查询仅用于检索) String finalSystem = baseSystem + ragPipeline.buildRagContextBlock(rag.contextText()); - return new ChatRequest(ctx, rag.rewrittenQuery(), finalSystem, Optional.empty(), + return new ChatRequest(ctx, ctx.message(), finalSystem, Optional.empty(), globalPrompt, rag.contextText(), rag.documents() != null ? rag.documents().size() : 0, "RAG", rag.searchMode(), rag.documents(), null); } diff --git a/src/main/java/com/wok/supportbot/app/ChatRequest.java b/src/main/java/com/wok/supportbot/app/ChatRequest.java index 14b3610..c83287e 100644 --- a/src/main/java/com/wok/supportbot/app/ChatRequest.java +++ b/src/main/java/com/wok/supportbot/app/ChatRequest.java @@ -15,7 +15,7 @@ import java.util.Optional; * 字段含义: *
      *
    • {@link #faqAnswer()}:FAQ 命中时直接返回标准答案,跳过 ChatClient 调用
    • - *
    • {@link #finalMessage()}:传给模型的用户消息(RAG 场景为重写后的查询,否则为原始 message)
    • + *
    • {@link #finalMessage()}:传给模型的用户消息(始终为原始 message,查询重写仅用于检索)
    • *
    • {@link #finalSystemPrompt()}:传给模型的系统提示词(角色人设 + RAG 资料块),可为空
    • *
    • {@link #globalPrompt()}:全局提示词快照(来自 system_config.ai_system_prompt),可为空
    • *
    • {@link #ragContextText()}:RAG 资料块文本(未注入时为空/null)
    • diff --git a/src/main/java/com/wok/supportbot/rag/RagContext.java b/src/main/java/com/wok/supportbot/rag/RagContext.java index 5a982a2..c651287 100644 --- a/src/main/java/com/wok/supportbot/rag/RagContext.java +++ b/src/main/java/com/wok/supportbot/rag/RagContext.java @@ -13,13 +13,13 @@ import java.util.Optional; *
        *
      • {@link #faqAnswer()} 命中 → 直接返回标准答案,跳过生成
      • *
      • 未命中 → 用 {@link #documents()} 展示引用来源,用 {@link #contextText()} 注入系统提示词, - * 用 {@link #rewrittenQuery()} 作为传给模型的用户消息
      • + * {@link #rewrittenQuery()} 仅用于检索,不作为传给模型的用户消息 *
      * * @param faqAnswer FAQ 命中的标准答案;未命中为 {@link Optional#empty()} * @param documents 检索命中的知识库片段(含 metadata),可为空 * @param contextText 拼接后的资料文本(用 {@code "\n\n---\n\n"} 分隔);无资料时为空串 - * @param rewrittenQuery 传给模型的用户消息:MULTI_QUERY 为原始 message,其余策略为重写后的查询 + * @param rewrittenQuery 检索用的改写查询(MULTI_QUERY 为原始 message,其余策略为重写后的查询),仅用于检索,不作为用户消息 * @param searchMode 真实检索模式(VECTOR / KEYWORD / HYBRID),当前主管道固定为 VECTOR * @param faqMatchResult FAQ 命中详情(含 matchType/score);未命中为 null */ From 9fe3ee68287f8c5c66e3da20c0851f86386ffd0b Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Wed, 26 Aug 2026 16:01:27 +0800 Subject: [PATCH 07/39] =?UTF-8?q?fix(sdk):=20=E4=BF=AE=E5=A4=8D=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E4=BC=9A=E8=AF=9D=E4=B8=8D=E6=98=BE=E7=A4=BA=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E9=99=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MessageConverter 落库时持久化多模态 Media 到 metadata._media - ConversationService 历史消息接口提取 _media 为顶层 images 字段 - SDK 前端历史映射补齐 images,复用 renderUserBubble 附件渲染 --- client/src/api.ts | 2 +- client/src/chat.ts | 12 ++++ .../converter/MessageConverter.java | 70 ++++++++++++++++++- .../service/ConversationService.java | 9 ++- 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/client/src/api.ts b/client/src/api.ts index 6971906..118db1d 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -649,7 +649,7 @@ export async function fetchConversationList( * 使用 /ai/sdk/conversation/{id}/messages,含账户归属校验 */ export async function fetchConversationMessages(conversationId: string): Promise<{ - messages: Array<{ messageType: string; content: string; createTime: string }>; + messages: Array<{ messageType: string; content: string; createTime: string; images?: ImageAttachment[] }>; total: number; }> { const params = new URLSearchParams(); diff --git a/client/src/chat.ts b/client/src/chat.ts index 1e39fc7..eb08893 100644 --- a/client/src/chat.ts +++ b/client/src/chat.ts @@ -181,6 +181,12 @@ async function loadHistoryFromBackend(): Promise { id: uuid(), role: msg.messageType === 'USER' ? 'user' : 'ai' as const, content: msg.content, + images: (msg.images || []).map((i) => ({ + name: i.name || '', + url: i.url, + mimeType: i.mimeType, + size: i.size, + })), timestamp: new Date(msg.createTime).getTime(), })); @@ -1019,6 +1025,12 @@ async function switchToConversation(conversationId: string): Promise { id: uuid(), role: msg.messageType === 'USER' ? 'user' : 'ai' as const, content: msg.content, + images: (msg.images || []).map((i) => ({ + name: i.name || '', + url: i.url, + mimeType: i.mimeType, + size: i.size, + })), timestamp: new Date(msg.createTime).getTime(), })); diff --git a/src/main/java/com/wok/supportbot/converter/MessageConverter.java b/src/main/java/com/wok/supportbot/converter/MessageConverter.java index d4b93b8..45b6c73 100644 --- a/src/main/java/com/wok/supportbot/converter/MessageConverter.java +++ b/src/main/java/com/wok/supportbot/converter/MessageConverter.java @@ -2,6 +2,9 @@ package com.wok.supportbot.converter; import com.wok.supportbot.entity.ChatMessage; import org.springframework.ai.chat.messages.*; +import org.springframework.ai.content.Media; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; import java.util.*; @@ -16,6 +19,8 @@ public class MessageConverter { private static final String META_KEY_TOOL_CALLS = "_toolCalls"; /** metadata 中存储 toolResponses 的内部键名 */ private static final String META_KEY_TOOL_RESPONSES = "_toolResponses"; + /** metadata 中存储多模态图片 Media 的内部键名 */ + private static final String META_KEY_MEDIA = "_media"; /** * 将 Spring AI Message 转换为数据库实体 ChatMessage @@ -54,6 +59,26 @@ public class MessageConverter { metadata.put(META_KEY_TOOL_RESPONSES, responsesJson); } + // 处理多模态图片 Media(仅用户消息携带),序列化到 metadata 供历史会话还原图片附件 + if (message instanceof UserMessage userMessage) { + List mediaList = userMessage.getMedia(); + if (mediaList != null && !mediaList.isEmpty()) { + List> mediaJson = new ArrayList<>(); + for (Media media : mediaList) { + Map m = new LinkedHashMap<>(); + Object data = media.getData(); + if (data != null) { + m.put("url", String.valueOf(data)); + } + if (media.getMimeType() != null) { + m.put("mimeType", media.getMimeType().toString()); + } + mediaJson.add(m); + } + metadata.put(META_KEY_MEDIA, mediaJson); + } + } + return ChatMessage.builder() .conversationId(conversationId) .messageType(message.getMessageType()) @@ -78,7 +103,11 @@ public class MessageConverter { : new HashMap<>(); return switch (messageType) { - case USER -> new UserMessage(text); + case USER -> { + List media = extractMedia(cleanMetadata); + yield media.isEmpty() ? new UserMessage(text) + : UserMessage.builder().text(text).media(media).build(); + } case ASSISTANT -> { List toolCalls = extractToolCalls(cleanMetadata); yield new AssistantMessage(text, cleanMetadata, toolCalls); @@ -135,4 +164,43 @@ public class MessageConverter { } return result; } + + /** + * 从 metadata 中提取并移除 _media 键,反序列化为 Media 列表。 + * 无媒体时返回空列表,保证多轮记忆不丢图片。 + */ + @SuppressWarnings("unchecked") + private static List extractMedia(Map metadata) { + Object raw = metadata.remove(META_KEY_MEDIA); + if (!(raw instanceof List list) || list.isEmpty()) { + return List.of(); + } + List result = new ArrayList<>(); + for (Object item : list) { + if (item instanceof Map) { + Map map = (Map) item; + Object url = map.get("url"); + if (url == null) { + continue; + } + result.add(Media.builder() + .data(url.toString()) + .mimeType(parseMimeTypeSafely(map.get("mimeType"))) + .build()); + } + } + return result; + } + + /** 安全解析 MIME 类型,缺失或非法时兜底为 image/png(与图片扩展名推断逻辑一致) */ + private static MimeType parseMimeTypeSafely(Object mimeType) { + if (mimeType == null) { + return MimeTypeUtils.IMAGE_PNG; + } + try { + return MimeTypeUtils.parseMimeType(mimeType.toString()); + } catch (Exception e) { + return MimeTypeUtils.IMAGE_PNG; + } + } } diff --git a/src/main/java/com/wok/supportbot/service/ConversationService.java b/src/main/java/com/wok/supportbot/service/ConversationService.java index f51a164..0b37a7c 100644 --- a/src/main/java/com/wok/supportbot/service/ConversationService.java +++ b/src/main/java/com/wok/supportbot/service/ConversationService.java @@ -283,7 +283,14 @@ public class ConversationService { item.put("conversationId", msg.getConversationId()); item.put("messageType", msg.getMessageType()); item.put("content", msg.getContent()); - item.put("metadata", msg.getMetadata()); + // 复制 metadata,提取内部键 _media 为顶层 images 字段(供历史会话还原图片附件),避免内部键泄漏 + Map metadata = msg.getMetadata() != null + ? new LinkedHashMap<>(msg.getMetadata()) : new LinkedHashMap<>(); + Object media = metadata.remove("_media"); + item.put("metadata", metadata); + if (media instanceof List mediaList && !mediaList.isEmpty()) { + item.put("images", mediaList); + } item.put("createTime", msg.getCreateTime()); result.add(item); } From 3038cd038729579332792a47b035bb1930e0216b Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Wed, 26 Aug 2026 17:51:35 +0800 Subject: [PATCH 08/39] =?UTF-8?q?fix(security):=20CORS=20=E6=8B=86?= =?UTF-8?q?=E5=88=86=E4=B8=BA=20SDK=20=E5=BC=80=E6=94=BE=E4=B8=8E=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=99=BD=E5=90=8D=E5=8D=95=E5=8F=8C=E8=BD=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 9338fcb 收紧 CORS 白名单后引发的线上 403 回归: 将 corsConfigurationSource 与 addCorsMappings 拆分为两套配置——SDK 第三方接入接口 (/ai/**、/category/tree、/category/list、/feedback、/attachment/upload)开放 allowedOriginPatterns("*"), 管理后台接口保持 allowed-origins 白名单防 CSRF;并在 application-prod.yml 白名单补上线上域名 https://aidoc.dgscdw.com:11300,同时记录 CORS 双轨制踩坑规范。 --- CLAUDE.md | 15 ++++++++++ .../com/wok/supportbot/config/CorsConfig.java | 14 +++++++-- .../supportbot/security/SecurityConfig.java | 30 ++++++++++++++----- src/main/resources/application-prod.yml | 2 +- 4 files changed, 50 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 68ad9c4..5e961eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,6 +201,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/views/DocUpload.vue b/frontend/src/views/DocUpload.vue index 085e2b1..898ceee 100644 --- a/frontend/src/views/DocUpload.vue +++ b/frontend/src/views/DocUpload.vue @@ -5,6 +5,16 @@
      +
      {{ tag }} +
      + 📁 选择文件夹上传 + 上传中 {{ folderProgress ?? 0 }}% + +
      @@ -35,11 +50,13 @@ diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index a3a80dc..e499a23 100644 --- a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java +++ b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java @@ -62,6 +62,13 @@ public class DatabaseInitConfig { safeInit("迁移 knowledge_document.extra_config 列", this::addDocumentExtraConfigColumn); safeInit("迁移 knowledge_document.file_path 列", this::addDocumentFilePathColumn); + safeInit("创建知识文档目录表 knowledge_folder", () -> { + if (!checkTableExists("knowledge_folder")) { + createKnowledgeFolderTable(); + } + }); + safeInit("迁移 knowledge_document.folder_id 列", this::addDocumentFolderIdColumn); + safeInit("创建客服角色表 customer_service_role", () -> { if (!checkTableExists("customer_service_role")) { createCustomerServiceRoleTable(); @@ -269,6 +276,7 @@ public class DatabaseInitConfig { private void verifyInitialization() { String[] expectedTables = { "chat_message", "knowledge_category", "knowledge_document", + "knowledge_folder", "customer_service_role", "customer_service_role_category", "customer_account", "conversation_session", "ai_model_config", "sensitive_word", "content_audit_log", "message_feedback", @@ -347,6 +355,26 @@ public class DatabaseInitConfig { jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_category_parent ON knowledge_category (parent_id)"); } + private void createKnowledgeFolderTable() { + String sql = """ + CREATE TABLE IF NOT EXISTS knowledge_folder ( + id BIGSERIAL PRIMARY KEY, + category_id BIGINT DEFAULT 0 NOT NULL, + parent_id BIGINT DEFAULT 0 NOT NULL, + name VARCHAR(255) NOT NULL, + sort_order INTEGER DEFAULT 0 NOT NULL, + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + is_delete BOOLEAN DEFAULT FALSE NOT NULL + ) + """; + jdbcTemplate.execute(sql); + + // 创建索引 + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_folder_category ON knowledge_folder (category_id)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_folder_parent ON knowledge_folder (parent_id)"); + } + private void createDocumentTable() { String sql = """ CREATE TABLE IF NOT EXISTS knowledge_document ( @@ -685,6 +713,25 @@ public class DatabaseInitConfig { } } + /** + * 自动添加 folder_id 列(文档目录功能新增字段) + * 幂等:已有列则跳过 + */ + private void addDocumentFolderIdColumn() { + try { + String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'knowledge_document' AND column_name = 'folder_id'"; + Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); + if (count != null && count == 0) { + log.info("添加 knowledge_document.folder_id 列"); + jdbcTemplate.execute("ALTER TABLE knowledge_document ADD COLUMN folder_id BIGINT DEFAULT 0 NOT NULL"); + } + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_folder ON knowledge_document (folder_id)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_dedup ON knowledge_document (category_id, folder_id, content_hash)"); + } catch (Exception e) { + log.error("添加 knowledge_document.folder_id 列失败,请手动执行: ALTER TABLE knowledge_document ADD COLUMN folder_id BIGINT DEFAULT 0 NOT NULL", e); + } + } + // ==================== P0-004: 内容安全过滤 ==================== private void createSensitiveWordTable() { @@ -1460,6 +1507,7 @@ public class DatabaseInitConfig { executeComment("COLUMN knowledge_document.file_size", "文件大小(字节)"); executeComment("COLUMN knowledge_document.content", "原文内容(截断预览)"); executeComment("COLUMN knowledge_document.category_id", "所属分类 ID(0 表示未分类)"); + executeComment("COLUMN knowledge_document.folder_id", "所属目录 ID(0 表示未指定目录,直接挂分类根)"); executeComment("COLUMN knowledge_document.tags", "标签(JSON 格式)"); executeComment("COLUMN knowledge_document.chunk_count", "分块数量"); executeComment("COLUMN knowledge_document.status", "处理状态: PROCESSING / READY / FAILED"); @@ -1472,6 +1520,17 @@ public class DatabaseInitConfig { executeComment("COLUMN knowledge_document.update_time", "更新时间"); executeComment("COLUMN knowledge_document.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); + // ===== knowledge_folder ===== + executeComment("TABLE knowledge_folder", "知识库文档目录表(支持分类下的目录树形结构)"); + executeComment("COLUMN knowledge_folder.id", "主键(雪花算法生成)"); + executeComment("COLUMN knowledge_folder.category_id", "所属分类 ID(关联 knowledge_category.id)"); + executeComment("COLUMN knowledge_folder.parent_id", "父目录 ID(0 表示该分类下的根目录)"); + executeComment("COLUMN knowledge_folder.name", "目录名称"); + executeComment("COLUMN knowledge_folder.sort_order", "排序权重(数值越大越靠前)"); + executeComment("COLUMN knowledge_folder.create_time", "创建时间"); + executeComment("COLUMN knowledge_folder.update_time", "更新时间"); + executeComment("COLUMN knowledge_folder.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); + // ===== customer_service_role ===== executeComment("TABLE customer_service_role", "客服角色表(定义客服角色的身份与系统提示词)"); executeComment("COLUMN customer_service_role.id", "主键"); diff --git a/src/main/java/com/wok/supportbot/controller/DocumentController.java b/src/main/java/com/wok/supportbot/controller/DocumentController.java index cf95720..4058951 100644 --- a/src/main/java/com/wok/supportbot/controller/DocumentController.java +++ b/src/main/java/com/wok/supportbot/controller/DocumentController.java @@ -74,6 +74,7 @@ public class DocumentController { * @param file 文件 * @param title 文档标题(可选,默认使用文件名) * @param categoryId 分类ID(可选) + * @param folderId 目录ID(可选) * @param tags 标签(可选) * @return 上传结果 */ @@ -83,12 +84,13 @@ public class DocumentController { @RequestParam("file") MultipartFile file, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long folderId, @RequestParam(required = false) List tags, @RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer overlap) { try { validateUploadFile(file); - KnowledgeDocument doc = documentService.uploadFile(file, title, categoryId, tags, chunkSize, overlap); + KnowledgeDocument doc = documentService.uploadFile(file, title, categoryId, folderId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "文件上传成功,正在后台处理", @@ -117,11 +119,12 @@ public class DocumentController { @RequestBody String content, @RequestParam String title, @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long folderId, @RequestParam(required = false) List tags, @RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer overlap) { try { - KnowledgeDocument doc = documentService.uploadString(content, title, categoryId, tags, chunkSize, overlap); + KnowledgeDocument doc = documentService.uploadString(content, title, categoryId, folderId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "文本内容上传成功,正在后台处理", @@ -144,12 +147,13 @@ public class DocumentController { @RequestParam("file") MultipartFile file, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long folderId, @RequestParam(required = false) List tags, @RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer overlap) { try { validateUploadFile(file); - KnowledgeDocument doc = documentService.uploadMarkdown(file, title, categoryId, tags, chunkSize, overlap); + KnowledgeDocument doc = documentService.uploadMarkdown(file, title, categoryId, folderId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "Markdown文件上传成功,正在后台处理", @@ -172,12 +176,13 @@ public class DocumentController { @RequestParam("file") MultipartFile file, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long folderId, @RequestParam(required = false) List tags, @RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer overlap) { try { validateUploadFile(file); - KnowledgeDocument doc = documentService.uploadJsonBasic(file, title, categoryId, tags, chunkSize, overlap); + KnowledgeDocument doc = documentService.uploadJsonBasic(file, title, categoryId, folderId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "JSON文件上传成功,正在后台处理", @@ -201,12 +206,13 @@ public class DocumentController { @RequestParam("fields") List fields, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long folderId, @RequestParam(required = false) List tags, @RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer overlap) { try { validateUploadFile(file); - KnowledgeDocument doc = documentService.uploadJsonFields(file, fields, title, categoryId, tags, chunkSize, overlap); + KnowledgeDocument doc = documentService.uploadJsonFields(file, fields, title, categoryId, folderId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "JSON文件(按字段)上传成功,正在后台处理", @@ -231,12 +237,13 @@ public class DocumentController { @RequestParam("pointer") String pointer, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long folderId, @RequestParam(required = false) List tags, @RequestParam(required = false) Integer chunkSize, @RequestParam(required = false) Integer overlap) { try { validateUploadFile(file); - KnowledgeDocument doc = documentService.uploadJsonPointer(file, pointer, title, categoryId, tags, chunkSize, overlap); + KnowledgeDocument doc = documentService.uploadJsonPointer(file, pointer, title, categoryId, folderId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "JSON文件(按指针)上传成功,正在后台处理", @@ -251,6 +258,38 @@ public class DocumentController { } } + /** + * 文件夹批量上传(按相对路径自动创建子目录) + */ + @PostMapping("/upload/folder") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> uploadFolder( + @RequestParam("files") MultipartFile[] files, + @RequestParam("relativePaths") String[] relativePaths, + @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long folderId, + @RequestParam(required = false) List tags, + @RequestParam(required = false) Integer chunkSize, + @RequestParam(required = false) Integer overlap) { + try { + Map result = documentService.uploadFolder( + Arrays.asList(files), Arrays.asList(relativePaths), + categoryId, folderId, tags, chunkSize, overlap); + int successCount = (int) result.get("successCount"); + int failCount = (int) result.get("failCount"); + return ResponseEntity.ok(Map.of( + "success", true, + "message", String.format("文件夹上传完成:成功 %d 个,失败 %d 个", successCount, failCount), + "data", result + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "文件夹上传失败:" + e.getMessage() + )); + } + } + // ==================== 文档管理 ==================== /** @@ -295,6 +334,7 @@ public class DocumentController { * @param page 页码(默认1) * @param size 每页大小(默认10) * @param categoryId 分类ID过滤(可选) + * @param folderId 目录ID过滤(可选,0 表示只看根目录) * @param status 状态过滤(PROCESSING/READY/FAILED,可选) * @param keyword 关键词搜索(模糊匹配标题和文件名,可选) * @param tag 标签筛选(精确匹配,可选) @@ -306,11 +346,14 @@ public class DocumentController { @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long folderId, @RequestParam(required = false) String status, @RequestParam(required = false) String keyword, - @RequestParam(required = false) String tag) { + @RequestParam(required = false) String tag, + @RequestParam(required = false) String sortField, + @RequestParam(required = false) String sortOrder) { try { - Map result = documentService.listDocuments(page, size, categoryId, status, keyword, tag); + Map result = documentService.listDocuments(page, size, categoryId, folderId, status, keyword, tag, sortField, sortOrder); Map data = new HashMap<>(); data.put("success", true); data.put("data", result.get("records")); diff --git a/src/main/java/com/wok/supportbot/controller/FolderController.java b/src/main/java/com/wok/supportbot/controller/FolderController.java new file mode 100644 index 0000000..44fd224 --- /dev/null +++ b/src/main/java/com/wok/supportbot/controller/FolderController.java @@ -0,0 +1,136 @@ +package com.wok.supportbot.controller; + +import com.wok.supportbot.entity.FolderNode; +import com.wok.supportbot.entity.KnowledgeFolder; +import com.wok.supportbot.service.FolderService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 知识库文档目录控制器 + * 提供目录的树形查询、扁平列表、创建、重命名、删除等功能 + */ +@RestController +public class FolderController { + + @Autowired + private FolderService folderService; + + /** + * 获取目录树 + */ + @GetMapping("/folder/tree") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> getFolderTree(@RequestParam(required = false) Long categoryId) { + try { + List tree = folderService.getFolderTree(categoryId); + return ResponseEntity.ok(Map.of( + "success", true, + "data", tree + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "获取目录树失败:" + e.getMessage() + )); + } + } + + /** + * 获取目录扁平列表 + */ + @GetMapping("/folder/list") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> listFolders(@RequestParam(required = false) Long categoryId) { + try { + List list = folderService.listFolders(categoryId); + return ResponseEntity.ok(Map.of( + "success", true, + "data", list + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "获取目录列表失败:" + e.getMessage() + )); + } + } + + /** + * 创建目录 + */ + @PostMapping("/folder") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> createFolder(@RequestBody Map body) { + try { + String name = (String) body.get("name"); + Long categoryId = body.get("categoryId") != null ? Long.valueOf(body.get("categoryId").toString()) : null; + Long parentId = body.get("parentId") != null ? Long.valueOf(body.get("parentId").toString()) : null; + Integer sortOrder = body.get("sortOrder") != null ? Integer.valueOf(body.get("sortOrder").toString()) : null; + + KnowledgeFolder folder = folderService.createFolder(name, categoryId, parentId, sortOrder); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "目录创建成功", + "data", folder + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "创建目录失败:" + e.getMessage() + )); + } + } + + /** + * 重命名目录 / 调整排序 + */ + @PutMapping("/folder/{id}") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> renameFolder( + @PathVariable Long id, + @RequestBody Map body) { + try { + String name = (String) body.get("name"); + Integer sortOrder = body.get("sortOrder") != null ? Integer.valueOf(body.get("sortOrder").toString()) : null; + + KnowledgeFolder folder = folderService.renameFolder(id, name, sortOrder); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "目录更新成功", + "data", folder + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "更新目录失败:" + e.getMessage() + )); + } + } + + /** + * 删除目录(级联删除子孙目录,并将其下文档移到分类根) + */ + @DeleteMapping("/folder/{id}") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> deleteFolder(@PathVariable Long id) { + try { + Map result = folderService.deleteFolder(id); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "目录删除成功", + "data", result + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "删除目录失败:" + e.getMessage() + )); + } + } +} diff --git a/src/main/java/com/wok/supportbot/dao/KnowledgeFolderMapper.java b/src/main/java/com/wok/supportbot/dao/KnowledgeFolderMapper.java new file mode 100644 index 0000000..60ec159 --- /dev/null +++ b/src/main/java/com/wok/supportbot/dao/KnowledgeFolderMapper.java @@ -0,0 +1,12 @@ +package com.wok.supportbot.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wok.supportbot.entity.KnowledgeFolder; +import org.apache.ibatis.annotations.Mapper; + +/** + * 知识库文档目录 Mapper + */ +@Mapper +public interface KnowledgeFolderMapper extends BaseMapper { +} diff --git a/src/main/java/com/wok/supportbot/entity/FolderNode.java b/src/main/java/com/wok/supportbot/entity/FolderNode.java new file mode 100644 index 0000000..3fe4b2a --- /dev/null +++ b/src/main/java/com/wok/supportbot/entity/FolderNode.java @@ -0,0 +1,63 @@ +package com.wok.supportbot.entity; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 目录树节点 - 用于返回树形结构 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class FolderNode implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 目录ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** + * 目录名称 + */ + private String name; + + /** + * 所属分类ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long categoryId; + + /** + * 父目录ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long parentId; + + /** + * 排序权重 + */ + private Integer sortOrder; + + /** + * 目录内文档数量 + */ + private Integer documentCount; + + /** + * 子目录列表 + */ + private List children; +} diff --git a/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java b/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java index e927c12..f6653f9 100644 --- a/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java +++ b/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java @@ -75,6 +75,13 @@ public class KnowledgeDocument implements Serializable { @JsonSerialize(using = ToStringSerializer.class) private Long categoryId; + /** + * 所属目录ID - 0表示未指定目录(直接挂分类根) + */ + @TableField("folder_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long folderId; + /** * 标签列表(JSON数组) */ diff --git a/src/main/java/com/wok/supportbot/entity/KnowledgeFolder.java b/src/main/java/com/wok/supportbot/entity/KnowledgeFolder.java new file mode 100644 index 0000000..3897345 --- /dev/null +++ b/src/main/java/com/wok/supportbot/entity/KnowledgeFolder.java @@ -0,0 +1,80 @@ +package com.wok.supportbot.entity; + +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.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 知识库文档目录表 - 支持分类下的目录树形结构 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@TableName("knowledge_folder") +public class KnowledgeFolder implements Serializable { + + @Serial + @TableField(exist = false) + private static final long serialVersionUID = 1L; + + /** + * 目录ID + */ + @TableId(value = "id", type = IdType.ASSIGN_ID) + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** + * 所属分类ID + */ + @TableField("category_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long categoryId; + + /** + * 父目录ID,0=该分类下的根目录 + */ + @TableField("parent_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long parentId; + + /** + * 目录名称 + */ + @TableField("name") + private String name; + + /** + * 排序权重(越大越靠前) + */ + @TableField("sort_order") + private Integer sortOrder; + + /** + * 创建时间 + */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新时间 + */ + @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + + /** + * 逻辑删除标志 - false:未删除, true:已删除(逻辑删除) + */ + @TableField("is_delete") + @TableLogic + private boolean isDelete; +} diff --git a/src/main/java/com/wok/supportbot/service/DocumentService.java b/src/main/java/com/wok/supportbot/service/DocumentService.java index d3abc4c..4ff9b66 100644 --- a/src/main/java/com/wok/supportbot/service/DocumentService.java +++ b/src/main/java/com/wok/supportbot/service/DocumentService.java @@ -12,6 +12,7 @@ import com.wok.supportbot.document.transform.MyTokenTextSplitter; import com.wok.supportbot.entity.CategoryNode; import com.wok.supportbot.entity.KnowledgeCategory; import com.wok.supportbot.entity.KnowledgeDocument; +import com.wok.supportbot.entity.KnowledgeFolder; import com.wok.supportbot.entity.SearchResult; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.document.Document; @@ -73,6 +74,9 @@ public class DocumentService { @Autowired private DocumentProcessingService documentProcessingService; + @Autowired + private FolderService folderService; + @Autowired private com.wok.supportbot.config.FileStorageConfig fileStorageConfig; @@ -97,6 +101,7 @@ public class DocumentService { * @param chunkSize 分块大小(可选,覆盖全局配置) * @param overlap 重叠大小(可选,覆盖全局配置) * @param filePath 原始文件存储路径(可选,null表示无原始文件) + * @param folderId 目录ID(可选,null/0表示不指定目录) * @return 创建完成的文档记录(status=PROCESSING) */ @Transactional(rollbackFor = Exception.class) @@ -104,13 +109,22 @@ public class DocumentService { String fileType, Long fileSize, String content, Long categoryId, List tags, Integer chunkSize, Integer overlap, - String filePath) { - // 0. 内容去重检查 + String filePath, Long folderId) { + // 0. 若指定目录,校验目录存在并强制使用目录所属分类 + if (folderId != null && folderId > 0) { + KnowledgeFolder folder = folderService.getFolderById(folderId); + if (folder == null) { + throw new RuntimeException("目录不存在"); + } + categoryId = folder.getCategoryId(); + } + + // 1. 内容去重检查 String contentHash = computeContentHash(content); if (contentHash != null) { - String duplicateTitle = checkContentDuplicate(contentHash, categoryId); + String duplicateTitle = checkContentDuplicate(contentHash, categoryId, folderId); if (duplicateTitle != null) { - throw new RuntimeException("文档内容重复,该分类下已有相同内容的文档: " + duplicateTitle); + throw new RuntimeException("文档内容重复,该目录下已有相同内容的文档: " + duplicateTitle); } } @@ -119,7 +133,7 @@ public class DocumentService { if (chunkSize != null) extraConfig.put("chunkSize", chunkSize); if (overlap != null) extraConfig.put("overlap", overlap); - // 1. 创建文档记录(状态 PROCESSING) + // 2. 创建文档记录(状态 PROCESSING) KnowledgeDocument docRecord = KnowledgeDocument.builder() .title(title != null ? title : sourceName) .sourceName(sourceName) @@ -128,6 +142,7 @@ public class DocumentService { .filePath(filePath) .content(content != null && content.length() > 2000 ? content.substring(0, 2000) : content) .categoryId(categoryId != null ? categoryId : 0L) + .folderId(folderId != null ? folderId : 0L) .tags(tags != null ? Map.of("tags", tags) : null) .contentHash(contentHash) .enabled(true) @@ -137,7 +152,7 @@ public class DocumentService { .build(); documentMapper.insert(docRecord); - // 2. 触发异步处理(分块 → 关键词 → 向量化 → 更新状态) + // 3. 触发异步处理(分块 → 关键词 → 向量化 → 更新状态) documentProcessingService.processDocumentAsync( docRecord.getId(), documents, sourceName, title, categoryId, tags, chunkSize, overlap); @@ -149,8 +164,8 @@ public class DocumentService { /** * 解析文件并上传(同时保存原始文件到本地) */ - public KnowledgeDocument uploadFile(MultipartFile file, String title, Long categoryId, List tags, - Integer chunkSize, Integer overlap) { + public KnowledgeDocument uploadFile(MultipartFile file, String title, Long categoryId, Long folderId, + List tags, Integer chunkSize, Integer overlap) { // 1. 先保存原始文件到本地磁盘(必须在解析之前,因为 transferTo 只能调用一次) String relativePath = saveFileToLocal(file); java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile(); @@ -168,7 +183,7 @@ public class DocumentService { documents.get(0).getText(), categoryId, tags, chunkSize, overlap, - relativePath); + relativePath, folderId); } /** @@ -201,18 +216,18 @@ public class DocumentService { /** * 解析字符串并上传(无原始文件) */ - public KnowledgeDocument uploadString(String content, String title, Long categoryId, List tags, - Integer chunkSize, Integer overlap) { + public KnowledgeDocument uploadString(String content, String title, Long categoryId, Long folderId, + List tags, Integer chunkSize, Integer overlap) { List documents = simpleStringDocumentReader.read(content); return uploadDocument(documents, title, title, "txt", - (long) content.length(), content, categoryId, tags, chunkSize, overlap, null); + (long) content.length(), content, categoryId, tags, chunkSize, overlap, null, folderId); } /** * 解析 Markdown 文件并上传 */ - public KnowledgeDocument uploadMarkdown(MultipartFile file, String title, Long categoryId, List tags, - Integer chunkSize, Integer overlap) { + public KnowledgeDocument uploadMarkdown(MultipartFile file, String title, Long categoryId, Long folderId, + List tags, Integer chunkSize, Integer overlap) { // 1. 先保存原始文件 String relativePath = saveFileToLocal(file); java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile(); @@ -229,14 +244,14 @@ public class DocumentService { content, categoryId, tags, chunkSize, overlap, - relativePath); + relativePath, folderId); } /** * 解析 JSON 文件(基本方式)并上传 */ - public KnowledgeDocument uploadJsonBasic(MultipartFile file, String title, Long categoryId, List tags, - Integer chunkSize, Integer overlap) { + public KnowledgeDocument uploadJsonBasic(MultipartFile file, String title, Long categoryId, Long folderId, + List tags, Integer chunkSize, Integer overlap) { // 1. 先保存原始文件 String relativePath = saveFileToLocal(file); java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile(); @@ -253,14 +268,14 @@ public class DocumentService { content, categoryId, tags, chunkSize, overlap, - relativePath); + relativePath, folderId); } /** * 解析 JSON 文件(按字段)并上传 */ public KnowledgeDocument uploadJsonFields(MultipartFile file, List fields, String title, - Long categoryId, List tags, + Long categoryId, Long folderId, List tags, Integer chunkSize, Integer overlap) { // 1. 先保存原始文件 String relativePath = saveFileToLocal(file); @@ -278,14 +293,14 @@ public class DocumentService { content, categoryId, tags, chunkSize, overlap, - relativePath); + relativePath, folderId); } /** * 解析 JSON 文件(按指针)并上传 */ public KnowledgeDocument uploadJsonPointer(MultipartFile file, String pointer, String title, - Long categoryId, List tags, + Long categoryId, Long folderId, List tags, Integer chunkSize, Integer overlap) { // 1. 先保存原始文件 String relativePath = saveFileToLocal(file); @@ -303,7 +318,117 @@ public class DocumentService { content, categoryId, tags, chunkSize, overlap, - relativePath); + relativePath, folderId); + } + + /** + * 文件夹批量上传:按相对路径解析子目录结构,逐文件上传 + * + * @param files 文件列表 + * @param relativePaths 每个文件对应的相对路径(如 "dir1/dir2/file.txt") + * @param categoryId 分类ID(可选,baseFolderId 为 0 时作为目标分类) + * @param folderId 基础目录ID(可选,null/0 表示从分类根目录开始) + * @param tags 标签列表(可选) + * @param chunkSize 分块大小(可选) + * @param overlap 重叠大小(可选) + * @return 批量上传结果(successCount/failCount/details) + */ + public Map uploadFolder(List files, List relativePaths, + Long categoryId, Long folderId, List tags, + Integer chunkSize, Integer overlap) { + if (files == null || files.isEmpty() || relativePaths == null || relativePaths.isEmpty()) { + throw new RuntimeException("文件和路径列表不能为空"); + } + if (files.size() != relativePaths.size()) { + throw new RuntimeException("文件与路径数量不一致"); + } + if (files.size() > 500) { + throw new RuntimeException("单次文件夹上传最多支持 500 个文件"); + } + + // 解析目标分类:基础目录存在时以其所属分类为准,否则使用入参分类 + Long baseFolderId = folderId != null ? folderId : 0L; + Long targetCategoryId; + if (baseFolderId > 0) { + KnowledgeFolder base = folderService.getFolderById(baseFolderId); + if (base == null) { + throw new RuntimeException("目录不存在"); + } + targetCategoryId = base.getCategoryId(); + } else { + targetCategoryId = categoryId; + } + + int successCount = 0; + int failCount = 0; + List> details = new ArrayList<>(); + + for (int i = 0; i < files.size(); i++) { + String originalPath = relativePaths.get(i); + try { + String sanitized = sanitizeRelativePath(originalPath); + List segs = new ArrayList<>(List.of(sanitized.split("/"))); + // 目录段 = 去掉最后一段(文件名) + List dirSegs = segs.subList(0, segs.size() - 1); + + Long targetFolderId; + if (!dirSegs.isEmpty()) { + if (targetCategoryId == null || targetCategoryId == 0) { + throw new RuntimeException("上传含子目录时必须先选择分类"); + } + targetFolderId = folderService.ensureFolderPath(targetCategoryId, baseFolderId, dirSegs); + } else { + targetFolderId = baseFolderId; + } + uploadFile(files.get(i), null, targetCategoryId, targetFolderId, tags, chunkSize, overlap); + successCount++; + } catch (Exception e) { + failCount++; + details.add(Map.of("file", originalPath, "error", e.getMessage())); + log.warn("文件夹上传单个文件失败: file={}", originalPath, e); + } + } + + Map result = new HashMap<>(); + result.put("successCount", successCount); + result.put("failCount", failCount); + result.put("details", details); + return result; + } + + /** + * 净化相对路径:反斜杠转正斜杠、去开头斜杠,校验非法段 + * + * @param path 原始相对路径 + * @return 净化后的路径 + */ + private String sanitizeRelativePath(String path) { + if (path == null || path.isEmpty()) { + throw new RuntimeException("文件相对路径不能为空"); + } + String normalized = path.replace('\\', '/'); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + if (normalized.isEmpty()) { + throw new RuntimeException("文件相对路径不能为空"); + } + String[] parts = normalized.split("/"); + for (String part : parts) { + if (part.isEmpty()) { + throw new RuntimeException("文件相对路径包含空目录段: " + path); + } + if (".".equals(part) || "..".equals(part)) { + throw new RuntimeException("文件相对路径包含非法目录段: " + path); + } + if (part.length() > 255) { + throw new RuntimeException("目录名超过 255 字符限制: " + part); + } + } + if (parts.length > 32) { + throw new RuntimeException("目录层级超过 32 层限制"); + } + return normalized; } // ==================== 文件下载 ==================== @@ -334,9 +459,10 @@ public class DocumentService { // ==================== 文档管理 ==================== /** - * 分页查询文档列表(手动分页,支持关键词搜索 + 标签筛选) + * 分页查询文档列表(手动分页,支持关键词搜索 + 标签筛选 + 目录筛选) */ - public Map listDocuments(int page, int size, Long categoryId, String status, String keyword, String tag) { + public Map listDocuments(int page, int size, Long categoryId, Long folderId, String status, + String keyword, String tag, String sortField, String sortOrder) { // 参数安全校验 if (page < 1) page = 1; if (size < 1 || size > 100) size = 10; @@ -349,6 +475,9 @@ public class DocumentService { if (categoryId != null && categoryId > 0) { countWrapper.eq("category_id", categoryId); } + if (folderId != null) { + countWrapper.eq("folder_id", folderId); + } if (status != null && !status.isEmpty()) { countWrapper.eq("status", status); } @@ -374,6 +503,9 @@ public class DocumentService { if (categoryId != null && categoryId > 0) { listWrapper.eq("category_id", categoryId); } + if (folderId != null) { + listWrapper.eq("folder_id", folderId); + } if (status != null && !status.isEmpty()) { listWrapper.eq("status", status); } @@ -389,7 +521,20 @@ public class DocumentService { log.warn("构建标签筛选 JSON 失败: tag={}", tag, e); } } - listWrapper.orderByDesc("create_time"); + // 排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 + Map sortColumns = Map.of( + "title", "title", + "fileType", "file_type", + "fileSize", "file_size", + "chunkCount", "chunk_count", + "createTime", "create_time"); + // sortField 可能为 null,需先判空,避免不可变 Map 的 getOrDefault(null) 触发 NPE + String sortColumn = sortField != null ? sortColumns.getOrDefault(sortField, "create_time") : "create_time"; + if ("asc".equalsIgnoreCase(sortOrder)) { + listWrapper.orderByAsc(sortColumn); + } else { + listWrapper.orderByDesc(sortColumn); + } listWrapper.last("LIMIT " + size + " OFFSET " + (page - 1) * size); List records = documentMapper.selectList(listWrapper); @@ -1035,10 +1180,13 @@ public class DocumentService { } /** - * 删除分类(不删除文档,仅清空关联) + * 删除分类(不删除文档,仅清空关联;级联清理该分类下的目录) */ @Transactional(rollbackFor = Exception.class) public void deleteCategory(Long id) { + // 级联清理该分类下的目录及其子孙目录(文档 folder_id 置 0,目录逻辑删除) + folderService.deleteFoldersByCategoryId(id); + // 将关联的文档 category_id 设为 0 KnowledgeDocument updateDoc = new KnowledgeDocument(); updateDoc.setCategoryId(0L); @@ -1074,15 +1222,18 @@ public class DocumentService { /** * 检查内容是否重复 * @param contentHash 内容哈希值 + * @param categoryId 分类ID + * @param folderId 目录ID(null/0 表示分类根目录) * @return 重复文档的标题,如果不存在重复则返回 null */ - private String checkContentDuplicate(String contentHash, Long categoryId) { + private String checkContentDuplicate(String contentHash, Long categoryId, Long folderId) { if (contentHash == null) { return null; } QueryWrapper wrapper = new QueryWrapper<>(); wrapper.eq("content_hash", contentHash); wrapper.eq("category_id", categoryId != null ? categoryId : 0L); + wrapper.eq("folder_id", folderId != null ? folderId : 0L); wrapper.select("title"); List existing = documentMapper.selectList(wrapper); return existing != null && !existing.isEmpty() ? existing.get(0).getTitle() : null; diff --git a/src/main/java/com/wok/supportbot/service/FolderService.java b/src/main/java/com/wok/supportbot/service/FolderService.java new file mode 100644 index 0000000..1ef3fc8 --- /dev/null +++ b/src/main/java/com/wok/supportbot/service/FolderService.java @@ -0,0 +1,313 @@ +package com.wok.supportbot.service; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.wok.supportbot.dao.KnowledgeCategoryMapper; +import com.wok.supportbot.dao.KnowledgeDocumentMapper; +import com.wok.supportbot.dao.KnowledgeFolderMapper; +import com.wok.supportbot.entity.FolderNode; +import com.wok.supportbot.entity.KnowledgeCategory; +import com.wok.supportbot.entity.KnowledgeDocument; +import com.wok.supportbot.entity.KnowledgeFolder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 知识库文档目录服务 + * 管理分类下的目录树形结构,支持目录的增删改查与级联处理 + */ +@Service +@Slf4j +public class FolderService { + + @Autowired + private KnowledgeFolderMapper folderMapper; + + @Autowired + private KnowledgeCategoryMapper categoryMapper; + + @Autowired + private KnowledgeDocumentMapper documentMapper; + + /** + * 获取目录树(按 parentId 拼装,parentId 为 null/0 的作为根节点) + * + * @param categoryId 分类ID(可选,为空时查询所有目录) + * @return 目录树根节点列表 + */ + public List getFolderTree(Long categoryId) { + QueryWrapper wrapper = new QueryWrapper<>(); + if (categoryId != null) { + wrapper.eq("category_id", categoryId); + } + wrapper.orderByAsc("sort_order"); + List folders = folderMapper.selectList(wrapper); + + Map nodeMap = new LinkedHashMap<>(); + List rootNodes = new ArrayList<>(); + + for (KnowledgeFolder folder : folders) { + FolderNode node = FolderNode.builder() + .id(folder.getId()) + .name(folder.getName()) + .categoryId(folder.getCategoryId()) + .parentId(folder.getParentId()) + .sortOrder(folder.getSortOrder()) + .documentCount(0) + .children(new ArrayList<>()) + .build(); + nodeMap.put(folder.getId(), node); + } + + for (FolderNode node : nodeMap.values()) { + if (node.getParentId() == null || node.getParentId() == 0) { + rootNodes.add(node); + } else { + FolderNode parent = nodeMap.get(node.getParentId()); + if (parent != null) { + parent.getChildren().add(node); + } else { + // 父目录缺失时降级为根节点,避免节点丢失 + rootNodes.add(node); + } + } + } + + return rootNodes; + } + + /** + * 获取目录扁平列表 + * + * @param categoryId 分类ID(可选) + * @return 目录列表 + */ + public List listFolders(Long categoryId) { + QueryWrapper wrapper = new QueryWrapper<>(); + if (categoryId != null) { + wrapper.eq("category_id", categoryId); + } + wrapper.orderByAsc("sort_order"); + return folderMapper.selectList(wrapper); + } + + /** + * 根据ID查询目录(逻辑删除的目录返回 null) + * + * @param id 目录ID + * @return 目录实体 + */ + public KnowledgeFolder getFolderById(Long id) { + if (id == null || id <= 0) { + return null; + } + return folderMapper.selectById(id); + } + + /** + * 创建目录 + * + * @param name 目录名称 + * @param categoryId 所属分类ID(必须存在且未删除) + * @param parentId 父目录ID(缺省/0 表示分类根目录) + * @param sortOrder 排序权重 + * @return 创建完成的目录实体 + */ + public KnowledgeFolder createFolder(String name, Long categoryId, Long parentId, Integer sortOrder) { + if (name == null || name.trim().isEmpty()) { + throw new RuntimeException("目录名称不能为空"); + } + name = name.trim(); + if (categoryId == null || categoryId <= 0) { + throw new RuntimeException("所属分类不存在"); + } + KnowledgeCategory category = categoryMapper.selectById(categoryId); + if (category == null || category.isDelete()) { + throw new RuntimeException("所属分类不存在"); + } + + Long parent = (parentId == null || parentId == 0) ? 0L : parentId; + if (parent > 0) { + KnowledgeFolder parentFolder = folderMapper.selectById(parent); + if (parentFolder == null || parentFolder.isDelete()) { + throw new RuntimeException("父目录不存在"); + } + if (parentFolder.getCategoryId() == null || !parentFolder.getCategoryId().equals(categoryId)) { + throw new RuntimeException("父目录不属于该分类"); + } + } + + // 同父同名查重 + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("category_id", categoryId); + wrapper.eq("parent_id", parent); + wrapper.eq("name", name); + if (folderMapper.selectCount(wrapper) > 0) { + throw new RuntimeException("同级目录下已存在同名目录"); + } + + KnowledgeFolder folder = KnowledgeFolder.builder() + .name(name) + .categoryId(categoryId) + .parentId(parent) + .sortOrder(sortOrder != null ? sortOrder : 0) + .build(); + folderMapper.insert(folder); + log.info("创建目录: id={}, name={}, categoryId={}, parentId={}", folder.getId(), name, categoryId, parent); + return folder; + } + + /** + * 重命名目录 / 调整排序 + * + * @param id 目录ID + * @param name 新名称(可选) + * @param sortOrder 新排序权重(可选) + * @return 更新后的目录实体 + */ + public KnowledgeFolder renameFolder(Long id, String name, Integer sortOrder) { + KnowledgeFolder folder = folderMapper.selectById(id); + if (folder == null) { + throw new RuntimeException("目录不存在"); + } + if (name != null && !name.trim().isEmpty()) { + String newName = name.trim(); + // 同父同名查重(排除自身) + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("category_id", folder.getCategoryId()); + wrapper.eq("parent_id", folder.getParentId()); + wrapper.eq("name", newName); + wrapper.ne("id", id); + if (folderMapper.selectCount(wrapper) > 0) { + throw new RuntimeException("同级目录下已存在同名目录"); + } + folder.setName(newName); + } + if (sortOrder != null) { + folder.setSortOrder(sortOrder); + } + folderMapper.updateById(folder); + return folder; + } + + /** + * 删除目录(级联逻辑删除自身与所有子孙目录,并将其下文档 folder_id 置 0) + * + * @param id 目录ID + * @return 删除结果(删除的目录数 + 移动的文档数) + */ + @Transactional(rollbackFor = Exception.class) + public Map deleteFolder(Long id) { + KnowledgeFolder folder = folderMapper.selectById(id); + if (folder == null) { + throw new RuntimeException("目录不存在"); + } + List ids = collectDescendantIds(id); + // 将该目录及其子孙目录下的文档 folder_id 置 0(不改变 categoryId) + KnowledgeDocument updateDoc = new KnowledgeDocument(); + updateDoc.setFolderId(0L); + int movedCount = documentMapper.update(updateDoc, + new QueryWrapper().in("folder_id", ids)); + // 逻辑删除目录(含子孙) + folderMapper.deleteBatchIds(ids); + log.info("删除目录及子孙: id={}, 删除目录数={}, 移动文档数={}", id, ids.size(), movedCount); + return Map.of("deletedFolders", ids.size(), "movedDocuments", movedCount); + } + + /** + * 清理指定分类下的所有目录及其子孙目录(用于删除分类时的级联处理) + * + * @param categoryId 分类ID + * @return 删除的目录数量 + */ + @Transactional(rollbackFor = Exception.class) + public int deleteFoldersByCategoryId(Long categoryId) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("category_id", categoryId); + List folders = folderMapper.selectList(wrapper); + + List ids = new ArrayList<>(); + for (KnowledgeFolder folder : folders) { + ids.addAll(collectDescendantIds(folder.getId())); + } + // 去重(多个根目录的子孙理论上不重叠,防御性处理) + ids = ids.stream().distinct().collect(Collectors.toList()); + if (ids.isEmpty()) { + return 0; + } + KnowledgeDocument updateDoc = new KnowledgeDocument(); + updateDoc.setFolderId(0L); + documentMapper.update(updateDoc, new QueryWrapper().in("folder_id", ids)); + folderMapper.deleteBatchIds(ids); + log.info("清理分类目录: categoryId={}, 删除目录数={}", categoryId, ids.size()); + return ids.size(); + } + + /** + * 从基础目录出发,逐段确保目录路径存在并返回最终目录ID + * + * @param categoryId 目标分类ID + * @param baseFolderId 基础目录ID(null/0 表示分类根目录) + * @param segments 目录段列表(不包含文件名) + * @return 最终目录ID + */ + public Long ensureFolderPath(Long categoryId, Long baseFolderId, List segments) { + Long currentParentId = (baseFolderId == null || baseFolderId == 0) ? 0L : baseFolderId; + for (String segment : segments) { + currentParentId = getOrCreateFolder(categoryId, currentParentId, segment); + } + return currentParentId; + } + + /** + * 在指定父目录下查找目录,不存在则创建,返回目录ID + * + * @param categoryId 分类ID + * @param parentId 父目录ID + * @param name 目录名称 + * @return 目录ID + */ + private Long getOrCreateFolder(Long categoryId, Long parentId, String name) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("category_id", categoryId); + wrapper.eq("parent_id", parentId != null ? parentId : 0L); + wrapper.eq("name", name); + List existing = folderMapper.selectList(wrapper); + if (existing != null && !existing.isEmpty()) { + return existing.get(0).getId(); + } + return createFolder(name, categoryId, parentId, null).getId(); + } + + /** + * 递归收集目录自身及所有子孙目录的ID + * + * @param rootId 根目录ID + * @return 目录ID列表(含根目录) + */ + private List collectDescendantIds(Long rootId) { + List all = new ArrayList<>(); + all.add(rootId); + List current = new ArrayList<>(); + current.add(rootId); + while (!current.isEmpty()) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.in("parent_id", current); + List children = folderMapper.selectList(wrapper); + List next = new ArrayList<>(); + for (KnowledgeFolder child : children) { + all.add(child.getId()); + next.add(child.getId()); + } + current = next; + } + return all; + } +} diff --git a/src/main/resources/init-database.sql b/src/main/resources/init-database.sql index 5ff61be..24349ec 100644 --- a/src/main/resources/init-database.sql +++ b/src/main/resources/init-database.sql @@ -1,7 +1,7 @@ -- ============================================================ -- AI 智能客服系统 - 数据库初始化脚本(完整版) -- 适用环境: PostgreSQL 12+ 且已安装 pgvector 扩展 --- 表数量: 27 张(含 vector_store;其余与 DatabaseInitConfig 的 26 张 expectedTables 对齐) +-- 表数量: 28 张(含 vector_store;其余与 DatabaseInitConfig 的 27 张 expectedTables 对齐) -- 注: vector_store 由 Spring AI 自动建表,此处手动建表作为备份方案 -- -- ⚠️ 开发规范:任何涉及建表、增删改列、初始数据的变更, @@ -87,6 +87,33 @@ COMMENT ON COLUMN knowledge_category.document_count IS '关联文档数(冗余 COMMENT ON COLUMN knowledge_category.create_time IS '创建时间'; COMMENT ON COLUMN knowledge_category.is_delete IS '逻辑删除'; +-- ============================================================ +-- 表 2.5: knowledge_folder — 知识库文档目录表 +-- ============================================================ +CREATE TABLE IF NOT EXISTS knowledge_folder ( + id BIGSERIAL PRIMARY KEY, + category_id BIGINT NOT NULL DEFAULT 0, + parent_id BIGINT NOT NULL DEFAULT 0, + name VARCHAR(255) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_delete BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_knowledge_folder_category ON knowledge_folder (category_id); +CREATE INDEX IF NOT EXISTS idx_knowledge_folder_parent ON knowledge_folder (parent_id); + +COMMENT ON TABLE knowledge_folder IS '知识库文档目录表(支持分类下的目录树形结构)'; +COMMENT ON COLUMN knowledge_folder.id IS '主键'; +COMMENT ON COLUMN knowledge_folder.category_id IS '所属分类ID(关联 knowledge_category.id)'; +COMMENT ON COLUMN knowledge_folder.parent_id IS '父目录ID(0 表示该分类下的根目录)'; +COMMENT ON COLUMN knowledge_folder.name IS '目录名称'; +COMMENT ON COLUMN knowledge_folder.sort_order IS '排序权重(越大越靠前)'; +COMMENT ON COLUMN knowledge_folder.create_time IS '创建时间'; +COMMENT ON COLUMN knowledge_folder.update_time IS '更新时间'; +COMMENT ON COLUMN knowledge_folder.is_delete IS '逻辑删除'; + -- ============================================================ -- 表 3: knowledge_document — 知识文档表 -- ============================================================ @@ -99,6 +126,7 @@ CREATE TABLE IF NOT EXISTS knowledge_document ( file_path VARCHAR(500), content TEXT, category_id BIGINT NOT NULL DEFAULT 0, + folder_id BIGINT NOT NULL DEFAULT 0, tags JSONB NOT NULL DEFAULT '{}', chunk_count INTEGER NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING', @@ -112,6 +140,8 @@ CREATE TABLE IF NOT EXISTS knowledge_document ( ); CREATE INDEX IF NOT EXISTS idx_knowledge_document_category ON knowledge_document (category_id); +CREATE INDEX IF NOT EXISTS idx_knowledge_document_folder ON knowledge_document (folder_id); +CREATE INDEX IF NOT EXISTS idx_knowledge_document_dedup ON knowledge_document (category_id, folder_id, content_hash); CREATE INDEX IF NOT EXISTS idx_knowledge_document_status ON knowledge_document (status); CREATE INDEX IF NOT EXISTS idx_knowledge_document_create_time ON knowledge_document (create_time DESC); CREATE INDEX IF NOT EXISTS idx_knowledge_document_content_hash ON knowledge_document (content_hash); @@ -126,6 +156,7 @@ COMMENT ON COLUMN knowledge_document.file_size IS '文件大小(字节)'; COMMENT ON COLUMN knowledge_document.file_path IS '原始文件存储路径(相对路径)'; COMMENT ON COLUMN knowledge_document.content IS '原文内容(截断预览)'; COMMENT ON COLUMN knowledge_document.category_id IS '所属分类ID'; +COMMENT ON COLUMN knowledge_document.folder_id IS '所属目录ID(0 表示未指定目录,直接挂分类根)'; COMMENT ON COLUMN knowledge_document.tags IS '标签(JSON)'; COMMENT ON COLUMN knowledge_document.chunk_count IS '分块数量'; COMMENT ON COLUMN knowledge_document.status IS '状态: PROCESSING / READY / FAILED'; @@ -137,6 +168,9 @@ COMMENT ON COLUMN knowledge_document.create_time IS '创建时间'; COMMENT ON COLUMN knowledge_document.update_time IS '更新时间'; COMMENT ON COLUMN knowledge_document.is_delete IS '逻辑删除'; +-- 老库升级:补充 folder_id 列(新建库已在 CREATE TABLE 中包含,此句幂等) +ALTER TABLE knowledge_document ADD COLUMN IF NOT EXISTS folder_id BIGINT DEFAULT 0 NOT NULL; + -- ============================================================ -- 表 4: customer_service_role — 客服角色表 -- ============================================================ From 367ec79401e94af5b74a698f39c3e99b747b2d75 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Wed, 2 Sep 2026 09:57:34 +0800 Subject: [PATCH 16/39] =?UTF-8?q?fix(document):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=B9=E4=B8=8A=E4=BC=A0=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E6=B1=A0=E8=80=97=E5=B0=BD=E4=B8=8E=E7=9B=AE=E5=BD=95=E6=A0=91?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 DocumentProcessingService 异步方法上的跨方法事务,AI 关键词提取/向量化期间不再占用数据库连接,避免文件夹批量上传时连接池被占满超时 - 新增有界 documentExecutor 线程池,替代默认无界 SimpleAsyncTaskExecutor,防线程爆炸与大模型限流 - 文档列表页目录树与列表改为左右独立滚动 --- frontend/src/views/DocList.vue | 12 ++++++++---- .../supportbot/config/AsyncExecutorConfig.java | 18 ++++++++++++++++++ .../service/DocumentProcessingService.java | 11 ++++------- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/frontend/src/views/DocList.vue b/frontend/src/views/DocList.vue index eb231c5..2448e46 100644 --- a/frontend/src/views/DocList.vue +++ b/frontend/src/views/DocList.vue @@ -1,5 +1,5 @@
      - - {{ fetchLoading ? '⏳ 获取中...' : '🔍 获取模型' }} + 获取模型 @@ -264,8 +254,8 @@ @@ -298,8 +288,7 @@ diff --git a/frontend/src/views/PipelineFlow.vue b/frontend/src/views/PipelineFlow.vue index c9a28e0..15eace8 100644 --- a/frontend/src/views/PipelineFlow.vue +++ b/frontend/src/views/PipelineFlow.vue @@ -26,13 +26,11 @@
      -
      -

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

      - 重试 -
      -
      -

      ⏳ 正在生成流程图...

      -
      + + + + +
      @@ -40,7 +38,7 @@ - - - - - + + + + + + From 1731a3ca60ef88ed081cb194ef05fe81a3300b69 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Thu, 10 Sep 2026 17:29:18 +0800 Subject: [PATCH 34/39] =?UTF-8?q?chore(deps):=20=E5=8D=87=E7=BA=A7=20Sprin?= =?UTF-8?q?g=20Boot=203.5.8=20/=20Spring=20AI=201.1.2=20/=20Spring=20AI=20?= =?UTF-8?q?Alibaba=201.1.2.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 升级到官方兼容矩阵组合(SAA 1.1.2.2 的官方基线即 Boot 3.5.8 + SAI 1.1.2)。 编译适配 - SAA 1.1.2.2 将内部类 DashscopeChatOptionsBuilder 更名为 DashScopeChatOptionsBuilder - Spring AI 1.1.x 起 AssistantMessage/ToolResponseMessage 带 metadata 的构造器改为 protected,MessageConverter 改用标准 builder 依赖增删 - MCP SDK 继续排除传递依赖并锁定 0.18.3:1.1.2 传递的是 0.17.0,其 JsonMapper 包名 为 json.jackson(无 jackson2),与本项目使用的 json.jackson2 不兼容,不锁版本会编译失败; 已核对 spring-ai-mcp 1.1.2 仅引用 McpSyncClient/McpAsyncClient/McpClient/ McpTransportContext/McpSchema/Assert,这些类 0.18.3 均存在,覆盖安全 - 删除 spring-security-oauth2-client:原为 Spring AI 1.0.x ToolCallingAutoConfiguration 的 ClassNotFound workaround,1.1.2 已无该耦合(已核对字节码常量池零 oauth2 引用) - 删除显式 victools jsonschema-generator:改由 spring-ai-model 传递引入(4.38.0), 顺带消除原先 4.38.0/4.37.0 混用 - 删除 kryo:唯一使用者 FileBasedChatMemory 为死代码,本提交一并删除 - 新增 jtokkit 1.1.0(OverlapTokenTextSplitter 直接使用) - 升 Knife4j 4.5.0、MyBatis-Plus 3.5.14、hutool 5.8.41、jjwt 0.12.7; Lombok 版本交由 Boot 父 POM 托管(1.18.42) 刻意不采纳的升级建议 - 不导入 spring-ai-alibaba-bom:该 BOM 的 dependencyManagement 并不包含 spring-ai-alibaba-starter-dashscope,导入后仍需显式写版本号,无收益 - 保留 spring-ai-openai / spring-ai-pgvector-store 的非 starter 坐标:项目手动构建 ChatModel/EmbeddingModel/VectorStore,换 starter 会额外引入 model-openai、chat-client、 chat-memory 三个自动配置,可能生成与手写工厂、DatabaseChatMemory 冲突的 Bean --- pom.xml | 65 ++++++++------ .../wok/supportbot/SupportBotApplication.java | 5 +- .../chatmemory/FileBasedChatMemory.java | 85 ------------------- .../supportbot/config/ChatModelFactory.java | 4 +- .../converter/MessageConverter.java | 13 ++- 5 files changed, 53 insertions(+), 119 deletions(-) delete mode 100644 src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java diff --git a/pom.xml b/pom.xml index ec46a77..9cd47c0 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.4.4 + 3.5.8 com.cdw @@ -14,7 +14,10 @@ 智能客服 17 - 1.0.0.4 + + 1.1.2 + + 1.1.2.2 false @@ -44,10 +47,12 @@ + org.springframework.ai spring-ai-bom - 1.0.1 + ${spring-ai.version} pom import @@ -66,6 +71,7 @@ + com.alibaba.cloud.ai spring-ai-alibaba-starter-dashscope @@ -79,7 +85,13 @@ - + org.springframework.ai spring-ai-starter-mcp-client @@ -91,7 +103,7 @@ - + io.modelcontextprotocol.sdk mcp @@ -102,7 +114,7 @@ cn.hutool hutool-all - 5.8.37 + 5.8.41 @@ -114,28 +126,24 @@ com.github.xiaoymin knife4j-openapi3-jakarta-spring-boot-starter - 4.4.0 + 4.5.0 - + + - com.github.victools - jsonschema-generator - 4.38.0 - - - - com.esotericsoftware - kryo - 5.6.2 + com.knuddels + jtokkit + 1.1.0 org.springframework.ai spring-ai-markdown-document-reader + org.projectlombok lombok - 1.18.36 true @@ -172,31 +180,31 @@ io.jsonwebtoken jjwt-api - 0.12.6 + 0.12.7 io.jsonwebtoken jjwt-impl - 0.12.6 + 0.12.7 runtime io.jsonwebtoken jjwt-jackson - 0.12.6 + 0.12.7 runtime - - - org.springframework.security - spring-security-oauth2-client - + com.baomidou mybatis-plus-spring-boot3-starter - 3.5.12 + 3.5.14 @@ -238,7 +246,8 @@ org.projectlombok lombok - 1.18.36 + + ${lombok.version} diff --git a/src/main/java/com/wok/supportbot/SupportBotApplication.java b/src/main/java/com/wok/supportbot/SupportBotApplication.java index 78caa3b..57aad0c 100644 --- a/src/main/java/com/wok/supportbot/SupportBotApplication.java +++ b/src/main/java/com/wok/supportbot/SupportBotApplication.java @@ -8,8 +8,9 @@ import org.springframework.transaction.annotation.EnableTransactionManagement; /** * 主启动类 - * spring-security-oauth2-client 依赖已通过 pom.xml 补充, - * 确保 ToolCallingAutoConfiguration 不会抛出 ClassNotFoundException + *

      + * ChatModel / EmbeddingModel / VectorStore 均由项目内工厂手动构建(见 ChatModelFactory、 + * EmbeddingModelFactory、PgVectorStoreConfig),不使用 Spring AI 的自动配置。 */ @SpringBootApplication @EnableScheduling diff --git a/src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java b/src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java deleted file mode 100644 index 8fefb82..0000000 --- a/src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.wok.supportbot.chatmemory; - -import com.esotericsoftware.kryo.Kryo; -import com.esotericsoftware.kryo.io.Input; -import com.esotericsoftware.kryo.io.Output; -import org.objenesis.strategy.StdInstantiatorStrategy; -import org.springframework.ai.chat.memory.ChatMemory; -import org.springframework.ai.chat.messages.Message; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -/** - * 基于文件持久化的对话记忆 - */ -public class FileBasedChatMemory implements ChatMemory { - - private final String BASE_DIR; - private static final Kryo kryo = new Kryo(); - - static { - kryo.setRegistrationRequired(false); - // 设置实例化策略 - kryo.setInstantiatorStrategy(new StdInstantiatorStrategy()); - } - - // 构造对象时,指定文件保存目录 - public FileBasedChatMemory(String dir) { - this.BASE_DIR = dir; - File baseDir = new File(dir); - if (!baseDir.exists()) { - baseDir.mkdirs(); - } - } - - @Override - public void add(String conversationId, List messages) { - List conversationMessages = getOrCreateConversation(conversationId); - conversationMessages.addAll(messages); - saveConversation(conversationId, conversationMessages); - } - - @Override - public List get(String conversationId) { - return getOrCreateConversation(conversationId); - } - - @Override - public void clear(String conversationId) { - File file = getConversationFile(conversationId); - if (file.exists()) { - file.delete(); - } - } - - private List getOrCreateConversation(String conversationId) { - File file = getConversationFile(conversationId); - List messages = new ArrayList<>(); - if (file.exists()) { - try (Input input = new Input(new FileInputStream(file))) { - messages = kryo.readObject(input, ArrayList.class); - } catch (IOException e) { - e.printStackTrace(); - } - } - return messages; - } - - private void saveConversation(String conversationId, List messages) { - File file = getConversationFile(conversationId); - try (Output output = new Output(new FileOutputStream(file))) { - kryo.writeObject(output, messages); - } catch (IOException e) { - e.printStackTrace(); - } - } - - private File getConversationFile(String conversationId) { - return new File(BASE_DIR, conversationId + ".kryo"); - } -} diff --git a/src/main/java/com/wok/supportbot/config/ChatModelFactory.java b/src/main/java/com/wok/supportbot/config/ChatModelFactory.java index 15438cc..03185a4 100644 --- a/src/main/java/com/wok/supportbot/config/ChatModelFactory.java +++ b/src/main/java/com/wok/supportbot/config/ChatModelFactory.java @@ -157,7 +157,7 @@ public class ChatModelFactory { } DashScopeApi api = apiBuilder.build(); - DashScopeChatOptions.DashscopeChatOptionsBuilder optionsBuilder = DashScopeChatOptions.builder() + DashScopeChatOptions.DashScopeChatOptionsBuilder optionsBuilder = DashScopeChatOptions.builder() .withModel(config.getModelName()); if (config.getTemperature() != null) { optionsBuilder.withTemperature(config.getTemperature()); @@ -352,7 +352,7 @@ public class ChatModelFactory { /** * 从 extraConfig 中读取高级参数并设置到 DashScopeChatOptions.Builder */ - private void applyDashScopeExtraConfig(AiModelConfig config, DashScopeChatOptions.DashscopeChatOptionsBuilder optionsBuilder) { + private void applyDashScopeExtraConfig(AiModelConfig config, DashScopeChatOptions.DashScopeChatOptionsBuilder optionsBuilder) { Map extra = config.getExtraConfig(); if (extra == null || extra.isEmpty()) return; diff --git a/src/main/java/com/wok/supportbot/converter/MessageConverter.java b/src/main/java/com/wok/supportbot/converter/MessageConverter.java index 45b6c73..3d2972c 100644 --- a/src/main/java/com/wok/supportbot/converter/MessageConverter.java +++ b/src/main/java/com/wok/supportbot/converter/MessageConverter.java @@ -110,12 +110,21 @@ public class MessageConverter { } case ASSISTANT -> { List toolCalls = extractToolCalls(cleanMetadata); - yield new AssistantMessage(text, cleanMetadata, toolCalls); + // Spring AI 1.1.x 起 AssistantMessage 带 metadata 的构造器为 protected,改用标准 builder + yield AssistantMessage.builder() + .content(text) + .properties(cleanMetadata) + .toolCalls(toolCalls) + .build(); } case SYSTEM -> new SystemMessage(text); case TOOL -> { List responses = extractToolResponses(cleanMetadata); - yield new ToolResponseMessage(responses, cleanMetadata); + // Spring AI 1.1.x 起 ToolResponseMessage 构造器为 protected,改用标准 builder + yield ToolResponseMessage.builder() + .responses(responses) + .metadata(cleanMetadata) + .build(); } }; } From 84eea7564fa67b2869d142ea5a77908e9ba93100 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Thu, 10 Sep 2026 17:29:31 +0800 Subject: [PATCH 35/39] =?UTF-8?q?refactor:=20=E8=87=AA=E9=80=A0=E8=BD=AE?= =?UTF-8?q?=E5=AD=90=E6=94=B9=E7=94=A8=20Spring=20AI=20=E6=A0=87=E5=87=86?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E3=80=81=E6=B8=85=E7=90=86=E6=AD=BB=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=B9=B6=E4=BF=AE=E5=A4=8D=E6=97=A2=E6=9C=89=E7=BC=BA?= =?UTF-8?q?=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 组件替换(改用 1.1.x 标准组件) - 文档提取:删除自写 TikaDocumentReader(内部直接 new org.apache.tika.Tika()), 改用官方 org.springframework.ai.reader.tika.TikaDocumentReader。 pom 早已引入 spring-ai-tika-document-reader 却从未使用其类,属典型「引了标准依赖却手写实现」 - 意图识别:IntentRouter 的手写正则解析(含去 BOM/零宽字符/全角空格等约 60 行防御逻辑) 改用标准 BeanOutputConverter,保留原有降级语义(空输入/解析失败 → RAG) - 推荐问题:SuggestionGenerator 的哨兵字符串 + 代码块剥离 + 按行降级解析改用 ChatClient.entity(ParameterizedTypeReference>),整体删除 SuggestionResponseParser - 分块:删除 MyTokenTextSplitter 薄壳,新增 OverlapTokenTextSplitter 分块器 overlap 缺陷修复(本次验证中发现并修复) - 旧实现把 knowledge.chunk.overlap 传进了 TokenTextSplitter 第 2 个形参 minChunkSizeChars 位, overlap 从未生效(Spring AI 的 TokenTextSplitter 根本没有 overlap 形参) - 新实现继承标准 TextSplitter,复刻 TokenTextSplitter 全部切分语义,仅把前进步长改为 chunkSize - overlap,使重叠真正生效 - 验证中发现:标点截断会缩短本块消耗的 token 数,与 overlap 叠加后可把前进步长压到 1 个 token,分块数膨胀 10 倍(实测 349 块 vs 修复后 44 块)。故仅当截断后仍能前进 至少 (chunkSize - overlap) / 2 个 token 时才采用该截断,否则宁可切断句子 - overlap=0 时与标准 TokenTextSplitter 逐块一致(已对比验证) - ⚠️ 存量文档需重新分块 + 重新向量化(POST /document/batch/reprocess),否则新旧向量口径混杂 其他既有缺陷修复 - RerankerService:原用 new RestTemplate() 且无任何超时,慢 provider 会把检索线程拖到 TCP 超时;改用 RestClient + JdkClientHttpRequestFactory 显式设置 connect/read 超时(各 3s) - McpServerConfigController:MCP Server 增删改/启停/全量刷新后未清 AssistantApp 的 ChatClient 缓存,导致继续使用旧工具集;现补调 clearCache() 死代码清理(均已 grep 确认零引用) - 删除 FileBasedChatMemory、ReReadingAdvisor(零装配且 before() 逻辑为 no-op)、 SseEventBuilder(零引用)、SuggestionResponseParser - McpToolCallback 删除 EVENTS ThreadLocal 与 drainEvents()/resetEvents()(零调用), 保留在用的 MCP_EVENTS_KEY/MCP_ROUNDS_KEY ToolContext 机制 文档 - 同步更新 CLAUDE.md / README.md / DEPLOY.md 的版本号、组件说明与架构描述 - 修正 CLAUDE.md 中与代码不符的既有描述:主启动类并未排除 PgVectorStoreAutoConfiguration (项目用的是非 starter 坐标,classpath 上本就没有该自动配置);分类过滤实现已完成, 原「Spring AI filter 支持有限」的 TODO 已不成立 --- CLAUDE.md | 33 +- DEPLOY.md | 2 +- README.md | 31 +- .../supportbot/advisor/ReReadingAdvisor.java | 43 --- .../com/wok/supportbot/app/ChatResult.java | 5 +- .../supportbot/app/SuggestionGenerator.java | 31 +- .../app/SuggestionResponseParser.java | 294 ------------------ .../controller/McpServerConfigController.java | 14 + .../document/extract/TikaDocumentReader.java | 73 ----- .../transform/MyTokenTextSplitter.java | 67 ---- .../transform/OverlapTokenTextSplitter.java | 252 +++++++++++++++ .../wok/supportbot/mcp/McpToolCallback.java | 36 +-- .../wok/supportbot/mcp/SseEventBuilder.java | 73 ----- .../wok/supportbot/rag/RerankerService.java | 50 ++- .../service/DocumentProcessingService.java | 34 +- .../supportbot/service/DocumentService.java | 13 +- .../wok/supportbot/service/IntentRouter.java | 100 ++---- 17 files changed, 436 insertions(+), 715 deletions(-) delete mode 100644 src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java delete mode 100644 src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java delete mode 100644 src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java delete mode 100644 src/main/java/com/wok/supportbot/document/transform/MyTokenTextSplitter.java create mode 100644 src/main/java/com/wok/supportbot/document/transform/OverlapTokenTextSplitter.java delete mode 100644 src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java diff --git a/CLAUDE.md b/CLAUDE.md index af43d11..87558f0 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,7 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支 - **Open API**: `OpenApiController` 已接入 `ChatPipeline`,补齐角色/RAG/FAQ/MCP/分类隔离能力 ### 文档处理管道 -`DocumentService.uploadDocument()` 统一流程:文档提取 → `MyTokenTextSplitter` 分块 → 为每块写 metadata → 按批向量化(默认 50 块/批,配置项 `knowledge.vector.batch-size`)`pgVectorVectorStore.add(batch)` 入库。每个分块的 metadata 注入 `documentId`、`chunkIndex`、`sourceName`、`title`、`categoryId`、`enabled` 关联 `knowledge_document` 表。 +`DocumentService.uploadDocument()` 统一流程:文档提取(官方 `org.springframework.ai.reader.tika.TikaDocumentReader` / `MarkdownDocumentReader` / `JsonReader`)→ `OverlapTokenTextSplitter` 分块 → 为每块写 metadata → 按批向量化(默认 50 块/批,配置项 `knowledge.vector.batch-size`)`pgVectorVectorStore.add(batch)` 入库。每个分块的 metadata 注入 `documentId`、`chunkIndex`、`sourceName`、`title`、`categoryId`、`enabled` 关联 `knowledge_document` 表。 **向量化加固**(`DocumentProcessingService`):逐批 try-catch 隔离,失败批只记录缺失区间后继续,已入库块保留;`chunk_count` 记实际入库块数,`error_message` 聚合"已入库 x/y 块 + 缺失区间 + 原因";文档级失败自动整体重试至多 2 次(仅瞬时/限流/超时类错误,4xx 不空转),重试前先清残留向量再重建。**无逐块 AI 关键词提取环节**(`MyKeywordEnricher` 已移除,其产出 `excerpt_keywords` 全库无检索消费点)。 @@ -81,6 +81,10 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支 - PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象 `'{}'`,非数组 `'[]'`) - **向量维度**: 由 `knowledge.vector.dimension` 配置(默认 1024)。修改后需执行 `DROP TABLE IF EXISTS vector_store CASCADE` 重建向量表,并重新上传知识库文档。距离类型: COSINE_DISTANCE,索引: HNSW - **分块配置**: `knowledge.chunk.*` 配置项(`ChunkConfig`),默认 chunkSize=200, overlap=100, minChunkSizeChars=10, maxNumChunks=5000, keepSeparator=true +- **分块器 `OverlapTokenTextSplitter`**: Spring AI 的 `TokenTextSplitter` **不支持 overlap**(构造器与 Builder 均无该形参,社区 PR #4054 不向 1.x 回迁)。项目继承标准 `TextSplitter` 基类自研了 `OverlapTokenTextSplitter`(`document/transform/`),复刻 `TokenTextSplitter` 全部切分语义,仅把前进步长由 `chunkSize` 改为 `chunkSize - overlap`,并用 jtokkit(CL100K_BASE,与上游同库)做 token 编码。 + - **前进步长必须有下限(`minAdvance()`)**: 标点截断会缩短本块消耗的 token 数,而步长 = `消耗量 - overlap`;若截断点靠前,步长会被压到 1 个 token,分块数成倍膨胀(实测 `chunkSize=60/overlap=30` 时 349 块 vs 修复后 44 块)。因此**仅当截断后仍能前进至少 `(chunkSize - overlap) / 2` 个 token 时才采用该截断**,否则宁可切断句子。`overlap=0` 时该下限取 1,与标准 `TokenTextSplitter` 行为**逐块一致**(已有对比验证)。 + - **历史缺陷已修复**: 旧 `MyTokenTextSplitter` 因形参错位,把 `overlap` 传进了 `minChunkSizeChars` 位,导致 `knowledge.chunk.overlap` 从未生效。修复后重叠真正生效,**分块边界与块数会变化**,存量文档需重新分块+向量化(`POST /document/batch/reprocess`)。 + - `minChunkLengthToEmbed` 固定为 10(`DocumentProcessingService.MIN_CHUNK_LENGTH_TO_EMBED`,ChunkConfig 无对应配置项) - **上传校验**: `ALLOWED_EXTENSIONS` 白名单 + 50MB 大小限制(`spring.servlet.multipart` 配置),前后端双重校验 - **文档去重**: `KnowledgeDocument.contentHash` 字段(SHA-256),上传时自动计算并查重 - **数据库自动初始化**: `DatabaseInitConfig` 在启动时检查并创建 `knowledge_category`/`knowledge_document`/`ai_model_config` 等表,对已存在的 `knowledge_document` 表会自动补加 `content_hash` 列。注意 `knowledge-base.sql` 脚本为早期版本,缺少此列,实际以 `DatabaseInitConfig` 为准 @@ -94,14 +98,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 中配置的提供商和模型 @@ -316,7 +325,7 @@ catch (e) { toast('操作失败', 'error') } - **会话导出**: `ConversationService.exportConversation()` 导出的 TXT 中包含反馈信息 ### 意图识别 + FAQ 精准匹配(P0-003) -- **IntentRouter**: LLM 单次调用做意图分类(FAQ/RAG/CHITCHAT),解析失败降级为 RAG +- **IntentRouter**: LLM 单次调用做意图分类(FAQ/RAG/CHITCHAT);结构化输出由标准组件 `BeanOutputConverter` 生成 JSON Schema 指令并反序列化结果,解析失败/结果非法降级为 RAG - **FaqMatchEngine**: 三级匹配策略 — 精确匹配 → 关键词匹配 → 向量语义匹配(阈值 `knowledge.faq.semantic-threshold`,默认 0.85) - **FAQ 向量化**: 复用现有 `DynamicEmbeddingModel`,向量存入 `faq_embedding` 表,新增/修改 FAQ 时异步计算 - **similar_questions 字段**: 使用 String 类型存储 JSON 数组字符串(PostgresJsonTypeHandler 期望对象格式,故不用 typeHandler) @@ -326,7 +335,7 @@ catch (e) { toast('操作失败', 'error') } - **SearchMode**: 枚举 VECTOR(默认)/ KEYWORD / HYBRID,向后兼容 - **HybridSearchService**: 多模式检索核心,KEYWORD 使用 PostgreSQL `tsvector` 全文检索,HYBRID 使用双路检索 + RRF 融合 - **RrfFusion**: RRF 融合算法 `score = Σ 1/(k + rank_i)`,k=60 -- **RerankerService**: 支持 DashScope + OpenAI 兼容提供商,通过 `ai_model_config` 表 RERANK 类型配置,超时 3s 自动 fallback +- **RerankerService**: 支持 DashScope + OpenAI 兼容提供商,通过 `ai_model_config` 表 RERANK 类型配置;HTTP 由 `RestClient` + `JdkClientHttpRequestFactory` 显式设置 connect/read 超时(各 3s,原 `RestTemplate` 无任何超时),超时/异常自动 fallback 到 RRF 原始排序 - **vector_store 全文检索**: 新增 `content_tsvector` 列 + GIN 索引 + PostgreSQL 触发器自动维护 - **前端**: `DocSearch.js` 增加检索模式下拉选择(向量/关键词/混合),结果标注来源模式 @@ -342,7 +351,7 @@ catch (e) { toast('操作失败', 'error') } ## 已知 TODO - `DocumentService.updateDocumentMetadata()`: Spring AI 无直接更新 vector_store metadata 的 API,向量元数据同步留后续 -- `DocumentService.searchDocuments()`: Spring AI 1.0.1 的 filter 支持有限,分类过滤暂未实现 +- `DocumentService.searchDocuments()`: **分类过滤已实现**(`FilterExpressionBuilder` 组合 `enabled` + `categoryId` 过滤表达式,向量检索异常时回退到本地 metadata 过滤)—— 原「Spring AI filter 支持有限」的 TODO 已不成立 - `CompressionQueryRewriter`: 当前传入空历史列表 -- MyBatis Plus 3.5.12 的 `mybatis-plus-spring-boot3-starter` 不含 `PaginationInnerInterceptor`,分页通过 SQL `LIMIT/OFFSET` 手动实现 +- MyBatis Plus `mybatis-plus-spring-boot3-starter` 不含 `PaginationInnerInterceptor`,分页通过 SQL `LIMIT/OFFSET` 手动实现 - `PgVectorStoreConfig.dimensions(1024)` 硬编码了向量维度,切换非 1024 维的 Embedding 模型时需修改并重建 vector_store 表 → **已修复:维度由 `knowledge.vector.dimension` 配置,启动时自动检测不匹配并告警** diff --git a/DEPLOY.md b/DEPLOY.md index 6294164..0e07ea8 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -749,4 +749,4 @@ sudo DB_PASS=your_strong_password /opt/support-bot/deploy.sh --- -*本教程基于项目当前代码状态(Spring Boot 3.4.4 / Java 17 / Spring AI 1.0.1 / PGVector)编写。* +*本教程基于项目当前代码状态(Spring Boot 3.5.8 / Java 17 / Spring AI 1.1.2 / Spring AI Alibaba 1.1.2.2 / PGVector)编写。* diff --git a/README.md b/README.md index dc4e2dd..c475ef0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 🤖 基于 Spring AI 和阿里云通义千问的智能客服机器人 > 🧠 支持 RAG 知识库检索 · 多种对话模式 · 结构化数据提取 · 知识库管理 -> 🧱 技术栈:Java 17 · Spring Boot 3.4.4 · Spring AI Alibaba · PGVector +> 🧱 技术栈:Java 17 · Spring Boot 3.5.8 · Spring AI Alibaba · PGVector ## 📌 项目简介 @@ -29,16 +29,15 @@ | 技术分类 | 技术组件 | 版本 | 用途说明 | |---------|---------|------|---------| -| **后端框架** | Spring Boot | 3.4.4 | 主框架,提供依赖注入和自动配置 | -| **AI框架** | Spring AI Alibaba | 1.0.0-M6.1 | AI集成框架,简化大模型调用 | +| **后端框架** | Spring Boot | 3.5.8 | 主框架,提供依赖注入和自动配置 | +| **AI框架** | Spring AI Alibaba | 1.1.2.2 | AI集成框架,简化大模型调用(Spring AI BOM 1.1.2) | | **大语言模型** | 阿里云通义千问 | qwen-turbo | 对话生成和文本理解 | | **Embedding模型** | 阿里云DashScope | text-embedding-v2 | 文本向量化(1024维) | | **数据库** | PostgreSQL + PGVector | 12+ | 关系数据存储 + 向量存储 | | **ORM框架** | MyBatis Plus | 3.5.12 | 数据库操作和对象映射 | -| **API文档** | Knife4j | 4.4.0 | Swagger UI增强版 | -| **工具库** | Hutool | 5.8.37 | 常用工具类集合 | -| **序列化** | Kryo | 5.6.2 | 高性能序列化框架 | -| **文档解析** | Apache Tika | 1.0.0 | 多格式文档内容提取 | +| **API文档** | Knife4j | 4.5.0 | Swagger UI增强版 | +| **工具库** | Hutool | 5.8.41 | 常用工具类集合 | +| **文档解析** | Apache Tika | 经 spring-ai-tika-document-reader | 多格式文档内容提取(使用 Spring AI 官方 `TikaDocumentReader`) | ## 🗃️ 数据库设计 @@ -162,12 +161,11 @@ src/main/java/com/wok/supportbot/ ├── SupportBotApplication.java # 主启动类 ├── advisor/ # AI对话增强器 │ ├── MyLoggerAdvisor.java # 日志记录顾问 -│ └── ReReadingAdvisor.java # 重读机制顾问 +│ └── ContentSafetyAdvisor.java # 内容安全顾问 ├── app/ # 核心应用服务 │ └── AssistantApp.java # 智能客服应用 ├── chatmemory/ # 聊天记忆管理 -│ ├── DatabaseChatMemory.java # 数据库记忆存储 -│ └── FileBasedChatMemory.java # 文件记忆存储 +│ └── DatabaseChatMemory.java # 数据库记忆存储 ├── config/ # 系统配置 │ ├── CorsConfig.java # 跨域请求配置 │ ├── MybatisPlusConfig.java # MyBatis Plus 配置 @@ -182,13 +180,12 @@ src/main/java/com/wok/supportbot/ │ ├── KnowledgeDocumentMapper.java # 知识文档 Mapper │ └── KnowledgeCategoryMapper.java # 知识分类 Mapper ├── document/ # 文档处理管道 -│ ├── extract/ # 文档提取器 -│ │ ├── TikaDocumentReader.java # Tika 多格式解析 -│ │ ├── MarkdownDocumentLoader.java # Markdown 解析 -│ │ ├── JsonDocumentLoader.java # JSON 解析(3种模式) +│ ├── extract/ # 文档提取器(均基于 Spring AI 官方读取器) +│ │ ├── MarkdownDocumentLoader.java # Markdown 解析(MarkdownDocumentReader) +│ │ ├── JsonDocumentLoader.java # JSON 解析(JsonReader,3种模式) │ │ └── SimpleStringDocumentReader.java # 纯文本读取 │ └── transform/ # 文档转换器 -│ └── MyTokenTextSplitter.java # Token 分块器 +│ └── OverlapTokenTextSplitter.java # 带重叠的 Token 分块器(继承标准 TextSplitter) ├── entity/ # 数据实体类 │ ├── ChatMessage.java # 聊天消息实体 │ ├── KnowledgeDocument.java # 知识文档实体 @@ -229,9 +226,9 @@ src/main/resources/ ``` 上传文件/文本 ↓ -[文档提取器] Tika / Markdown / JSON / 纯文本 +[文档提取器] Tika(官方 TikaDocumentReader)/ Markdown / JSON / 纯文本 ↓ -[Token 分块] MyTokenTextSplitter (200 token / 100 overlap) +[Token 分块] OverlapTokenTextSplitter (chunkSize=200, overlap=100) ↓ [元数据标注] metadata.documentId / chunkIndex / sourceName / title / categoryId / enabled ↓ diff --git a/src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java b/src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java deleted file mode 100644 index 3bff56a..0000000 --- a/src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.wok.supportbot.advisor; - -import org.springframework.ai.chat.client.ChatClientRequest; -import org.springframework.ai.chat.client.ChatClientResponse; -import org.springframework.ai.chat.client.advisor.api.AdvisorChain; -import org.springframework.ai.chat.client.advisor.api.BaseAdvisor; - -import java.util.HashMap; -import java.util.Map; - -/** - * 自定义 Re2 Advisor(适配 Spring AI 1.0.1 新 Advisor API) - * 可提高大型语言模型的推理能力 - */ -public class ReReadingAdvisor implements BaseAdvisor { - - @Override - public String getName() { - return this.getClass().getSimpleName(); - } - - @Override - public int getOrder() { - return 0; - } - - @Override - public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) { - // Re2 策略:将用户问题重复一次以增强推理 - // 通过 context 传递原始查询,在 prompt 中追加重复指令 - Map newContext = new HashMap<>(request.context()); - newContext.put("re2_enabled", true); - return ChatClientRequest.builder() - .prompt(request.prompt()) - .context(newContext) - .build(); - } - - @Override - public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) { - return response; - } -} diff --git a/src/main/java/com/wok/supportbot/app/ChatResult.java b/src/main/java/com/wok/supportbot/app/ChatResult.java index ab6a3e6..dc5e5ab 100644 --- a/src/main/java/com/wok/supportbot/app/ChatResult.java +++ b/src/main/java/com/wok/supportbot/app/ChatResult.java @@ -10,8 +10,9 @@ import java.util.List; * 除了回答文本,还携带本次触发的 MCP 工具调用事件,让同步对话也能像流式对话一样 * 展示工具调用过程(原仅流式 {@code appendMcpToolEvents} 追加事件,同步路径无事件)。 *

      - * 新增 {@code suggestions} 字段:AI 推荐问题列表(suggest-message-list), - * 由 {@link SuggestionResponseParser} 从 LLM 输出中分离,非 LLM 路径(FAQ/熔断)为空列表。 + * 新增 {@code suggestions} 字段:AI 推荐问题列表(suggest-message-list)。 + * 主链路(AssistantApp / ChatPipeline)恒为空列表,推荐问题改由 {@link SuggestionGenerator} + * 在 AI 回复结束后按需异步生成,非 LLM 路径(FAQ/熔断)亦为空。 * * @param text AI 回答文本 * @param mcpEvents 本次触发的 MCP 工具调用事件,无调用时为空列表 diff --git a/src/main/java/com/wok/supportbot/app/SuggestionGenerator.java b/src/main/java/com/wok/supportbot/app/SuggestionGenerator.java index c1806b2..29cbced 100644 --- a/src/main/java/com/wok/supportbot/app/SuggestionGenerator.java +++ b/src/main/java/com/wok/supportbot/app/SuggestionGenerator.java @@ -9,6 +9,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.MessageType; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -110,16 +111,15 @@ public class SuggestionGenerator { String historyText = formatHistory(history); String prompt = buildPrompt(historyText); - String raw = getChatClient().prompt() + // 标准结构化输出:BeanOutputConverter 自动把 JSON Schema 指令追加进 prompt, + // 并把模型返回的 JSON 数组反序列化为 List(可兼容 ```json 代码块包裹) + List raw = getChatClient().prompt() .system(prompt) .user("请根据历史对话生成推荐问题") .call() - .chatResponse() - .getResult() - .getOutput() - .getText(); + .entity(new ParameterizedTypeReference>() {}); - List suggestions = SuggestionResponseParser.parseSuggestionsJson(raw.strip()); + List suggestions = normalize(raw); log.info("推荐问题生成成功: chatId={}, count={}", chatId, suggestions.size()); return suggestions; } catch (Exception e) { @@ -128,6 +128,21 @@ public class SuggestionGenerator { } } + /** + * 规范化模型输出:过滤空白项、去首尾空白、去重,最多保留 3 条。 + */ + private List normalize(List raw) { + if (raw == null) { + return Collections.emptyList(); + } + return raw.stream() + .filter(StringUtils::hasText) + .map(String::strip) + .distinct() + .limit(3) + .collect(Collectors.toList()); + } + /** * 格式化历史消息为纯文本上下文。 */ @@ -171,8 +186,8 @@ public class SuggestionGenerator { "输出要求:\n" + "1. 问题必须与对话主题相关,引导用户深入咨询\n" + "2. 每条问题用中文,不超过 30 个字\n" + - "3. 只输出 JSON 数组格式,例如:[\"问题1\", \"问题2\", \"问题3\"]\n" + - "4. 不要输出任何其他解释、markdown 代码块或序号"; + "3. 每条问题是一个独立、可直接点击发送的完整问句\n" + + "4. 不要输出任何解释、markdown 代码块或序号(输出格式由系统统一约束)"; } /** diff --git a/src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java b/src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java deleted file mode 100644 index dccae92..0000000 --- a/src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java +++ /dev/null @@ -1,294 +0,0 @@ -package com.wok.supportbot.app; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.wok.supportbot.cache.SuggestionCache; -import lombok.extern.slf4j.Slf4j; -import reactor.core.publisher.Flux; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -/** - * 建议问题解析器:从 LLM 原始输出中分离 answer 和 suggestions。 - *

      - * 哨兵标记为 ___SUGGESTIONS___,之后为 JSON 字符串数组。 - * 支持同步路径(直接分割完整文本)和流式路径(滑动窗口检测哨兵)。 - */ -@Slf4j -public final class SuggestionResponseParser { - - /** 主哨兵:严格匹配提示词要求的下划线分隔标记 */ - private static final String SENTINEL = "___SUGGESTIONS___"; - - /** 兼容哨兵:模型偶尔不按指令输出时的大写变形 */ - private static final String LOOSE_SENTINEL = "SUGGESTIONS"; - - private static final ObjectMapper objectMapper = new ObjectMapper(); - - private SuggestionResponseParser() { - } - - /** - * 查找最佳哨兵位置:优先严格哨兵,其次兼容哨兵。 - *

      - * 兼容哨兵必须后面紧跟 JSON 数组特征('[' 或空白 + '['), - * 避免正文中出现 "SUGGESTIONS" 普通单词时误触发。 - * - * @param text 要搜索的文本 - * @return 哨兵起始位置,未找到返回 -1 - */ - private static int findSentinelIndex(String text) { - int strictIdx = text.lastIndexOf(SENTINEL); - if (strictIdx >= 0) { - return strictIdx; - } - - // 兼容模式:从后往前找大写 SUGGESTIONS,且后续需连接 JSON 数组 - int idx = text.lastIndexOf(LOOSE_SENTINEL); - while (idx >= 0) { - int after = idx + LOOSE_SENTINEL.length(); - if (after < text.length()) { - char c = text.charAt(after); - // 允许 SUGGESTIONS[...]、SUGGESTIONS [...]、SUGGESTIONS:\n[...] - if (c == '[' || Character.isWhitespace(c) || c == ':' || c == '-') { - return idx; - } - } - // 继续向前查找更早的兼容哨兵 - idx = text.lastIndexOf(LOOSE_SENTINEL, idx - 1); - } - return -1; - } - - /** - * 同步路径:按最后一次出现哨兵的位置分割完整文本。 - * - * @param rawText LLM 原始输出 - * @return 解析结果(answer + suggestions) - */ - public static ParsedResponse parse(String rawText) { - if (rawText == null || rawText.isEmpty()) { - return new ParsedResponse(rawText != null ? rawText : "", Collections.emptyList()); - } - - int lastIdx = findSentinelIndex(rawText); - if (lastIdx < 0) { - // 无哨兵标记,整段作为 answer - return new ParsedResponse(rawText, Collections.emptyList()); - } - - String answer = rawText.substring(0, lastIdx).trim(); - // 按实际匹配的哨兵长度截取后续内容(严格哨兵或兼容哨兵长度不同) - int matchedSentinelLength = rawText.startsWith(SENTINEL, lastIdx) ? SENTINEL.length() : LOOSE_SENTINEL.length(); - String suggestionsPart = rawText.substring(lastIdx + matchedSentinelLength).trim(); - - List suggestions = parseSuggestionsJson(suggestionsPart); - return new ParsedResponse(answer, suggestions); - } - - /** - * 流式路径:从原始 Flux 中分离 answer 和 suggestions。 - *

      - * 采用「安全缓冲区」策略防止哨兵泄漏到 UI: - *

        - *
      1. 每次 chunk 追加到累积缓冲区
      2. - *
      3. 安全区域 = 缓冲区去掉末尾 SENTINEL.length() 字符(预留哨兵跨 chunk 截断空间)
      4. - *
      5. 在安全区域内检查哨兵:找到则发出哨兵前的剩余内容,后续全进 suggestionsBuffer
      6. - *
      7. 未找到哨兵则发出安全区域内容
      8. - *
      9. doOnComplete 时:哨兵已发现则解析 suggestions;未发现则发出安全缓冲区内剩余内容
      10. - *
      - * - * @param rawStream LLM 原始输出流 - * @param cache 建议缓存 - * @param chatId 会话 ID - * @return 纯 answer 的 Flux(不含哨兵及之后的 suggestions) - */ - /** - * 可变状态容器(供 lambda 内部修改)。 - */ - private static class StreamState { - final StringBuilder buf = new StringBuilder(); - int emitted = 0; - boolean sentinelFound = false; - } - - public static Flux parseFromStream(Flux rawStream, SuggestionCache cache, String chatId) { - StreamState state = new StreamState(); - - return rawStream - .concatMap(chunk -> Flux.create(sink -> { - if (state.sentinelFound) { - sink.complete(); - return; - } - state.buf.append(chunk); - - int maxSentinelLen = Math.max(SENTINEL.length(), LOOSE_SENTINEL.length()); - int safeEnd = Math.max(0, state.buf.length() - maxSentinelLen); - if (safeEnd <= state.emitted) { - sink.complete(); - return; - } - - String safeRegion = state.buf.substring(state.emitted, safeEnd); - int sentinelIdx = findSentinelIndex(safeRegion); - - if (sentinelIdx >= 0) { - state.sentinelFound = true; - String beforeSentinel = safeRegion.substring(0, sentinelIdx); - if (!beforeSentinel.isEmpty()) { - sink.next(beforeSentinel); - } - // 确定实际匹配到的哨兵长度 - int matchedLen = safeRegion.startsWith(SENTINEL, sentinelIdx) - ? SENTINEL.length() - : LOOSE_SENTINEL.length(); - // 哨兵在安全区域内的位置 sentinelIdx,相对全缓冲区即 emitted + sentinelIdx - // emitted 推进到哨兵结束后,后续内容进 suggestions 解析 - state.emitted = state.emitted + sentinelIdx + matchedLen; - log.debug("流式路径检测到哨兵: chatId={}, pos={}, matchedLen={}", chatId, state.emitted, matchedLen); - } else { - if (!safeRegion.isEmpty()) { - sink.next(safeRegion); - } - state.emitted = safeEnd; - } - sink.complete(); - })) - // 流结束后发出安全缓冲区内未发出的残留内容(无哨兵场景) - .concatWith(Flux.defer(() -> { - if (!state.sentinelFound && state.emitted < state.buf.length()) { - String residual = state.buf.substring(state.emitted); - if (!residual.isEmpty()) { - return Flux.just(residual); - } - } - return Flux.empty(); - })) - .doOnComplete(() -> { - if (state.sentinelFound) { - String suggestionsPart = state.buf.length() > state.emitted - ? state.buf.substring(state.emitted) : ""; - List suggestions = parseSuggestionsJson(suggestionsPart.strip()); - if (!suggestions.isEmpty()) { - cache.put(chatId, suggestions); - log.info("流式 suggestions 解析成功: chatId={}, count={}", chatId, suggestions.size()); - } else { - log.debug("流式 suggestions 解析为空: chatId={}", chatId); - } - } - }); - } - - /** - * 容错解析 suggestions JSON 数组。 - * 先尝试标准 JSON 解析,失败则按行切分取前 3 条非空行。 - */ - static List parseSuggestionsJson(String jsonPart) { - if (jsonPart == null || jsonPart.isBlank()) { - return Collections.emptyList(); - } - - // 1. 尝试标准 JSON 解析 - String trimmed = jsonPart.strip(); - // 剥离旧版哨兵标记(___SUGGESTIONS___ 及兼容大写变形),取哨兵后的 JSON 部分。 - // 旧版 suggestion_prompt 会要求模型输出哨兵,若未剥离会被按行降级解析成垃圾条目。 - int sentinelIdx = findSentinelIndex(trimmed); - if (sentinelIdx >= 0) { - int matchedLen = trimmed.startsWith(SENTINEL, sentinelIdx) - ? SENTINEL.length() - : LOOSE_SENTINEL.length(); - trimmed = trimmed.substring(sentinelIdx + matchedLen).strip(); - } - // 去掉可能的 markdown 代码块包裹 - trimmed = trimCodeBlock(trimmed); - - try { - List parsed = objectMapper.readValue(trimmed, new TypeReference>() {}); - List result = new ArrayList<>(); - for (String s : parsed) { - if (s != null && !s.isBlank()) { - result.add(s.strip()); - } - } - if (!result.isEmpty()) { - return result.size() <= 3 ? result : result.subList(0, 3); - } - } catch (Exception e) { - log.debug("标准 JSON 解析 suggestions 失败,尝试按行切分: {}", e.getMessage()); - } - - // 2. 降级:按行切分,取前 3 条 - return parseByLines(trimmed); - } - - /** - * 降级解析:按行切分,去掉序号前缀后取前 3 条非空行。 - */ - private static List parseByLines(String text) { - List lines = text.lines() - .map(String::strip) - .map(SuggestionResponseParser::stripNumberPrefix) - .map(SuggestionResponseParser::stripQuotes) - .filter(s -> !s.isBlank()) - .collect(Collectors.toList()); - - if (lines.isEmpty()) { - return Collections.emptyList(); - } - return lines.size() <= 3 ? lines : lines.subList(0, 3); - } - - /** 去掉可能的 markdown 代码块包裹(支持 3 个及以上反引号) */ - private static String trimCodeBlock(String s) { - if (s.startsWith("```") && s.endsWith("```")) { - // 计算开头的反引号数量 - int openCount = 0; - while (openCount < s.length() && s.charAt(openCount) == '`') openCount++; - if (openCount >= 3 && s.endsWith("`".repeat(openCount))) { - String inner = s.substring(openCount, s.length() - openCount).strip(); - if (inner.startsWith("json")) { - inner = inner.substring(4).strip(); - } else if (inner.startsWith("JSON")) { - inner = inner.substring(4).strip(); - } - return inner; - } - } - return s; - } - - /** 去掉行首序号,如 "1." "2." "3." "1、" "2、" "3、" */ - private static String stripNumberPrefix(String s) { - if (s.length() >= 2 && Character.isDigit(s.charAt(0)) && - (s.charAt(1) == '.' || s.charAt(1) == '、' || s.charAt(1) == ')')) { - return s.substring(2).strip(); - } - return s; - } - - /** 去掉首尾引号 */ - private static String stripQuotes(String s) { - if (s.length() >= 2) { - char first = s.charAt(0); - char last = s.charAt(s.length() - 1); - if ((first == '"' && last == '"') || (first == '\'' && last == '\'') || - (first == '“' && last == '”')) { // 中文引号 " " - return s.substring(1, s.length() - 1).strip(); - } - } - return s; - } - - /** - * 解析结果值对象。 - * - * @param answer LLM 回答正文(不含哨兵及之后的 suggestions) - * @param suggestions 建议问题列表(0~3 条) - */ - public record ParsedResponse(String answer, List suggestions) { - } -} diff --git a/src/main/java/com/wok/supportbot/controller/McpServerConfigController.java b/src/main/java/com/wok/supportbot/controller/McpServerConfigController.java index 48b6880..f07b964 100644 --- a/src/main/java/com/wok/supportbot/controller/McpServerConfigController.java +++ b/src/main/java/com/wok/supportbot/controller/McpServerConfigController.java @@ -1,5 +1,6 @@ package com.wok.supportbot.controller; +import com.wok.supportbot.app.AssistantApp; import com.wok.supportbot.config.McpClientManager; import com.wok.supportbot.entity.McpServerConfig; import com.wok.supportbot.mcp.McpToolCallbackAdapter; @@ -29,6 +30,9 @@ public class McpServerConfigController { @Autowired private McpClientManager mcpClientManager; + @Autowired + private AssistantApp assistantApp; + // ==================== 分页列表 ==================== /** @@ -161,6 +165,8 @@ public class McpServerConfigController { } catch (Exception e) { log.warn("新建配置后创建客户端失败(不影响配置保存): {}", e.getMessage()); } + // MCP 工具清单可能变化,清空 AssistantApp 的 ChatClient 缓存,避免继续使用旧工具集 + assistantApp.clearCache(); } return ResponseEntity.ok(Map.of( "success", true, @@ -198,6 +204,8 @@ public class McpServerConfigController { Map updated = mcpServerConfigService.updateConfig(id, config); // 增量操作:仅重建更新的配置客户端,不影响其他已有连接 mcpClientManager.rebuildClient(id); + // MCP 工具清单可能变化,清空 AssistantApp 的 ChatClient 缓存,避免继续使用旧工具集 + assistantApp.clearCache(); return ResponseEntity.ok(Map.of( "success", true, "data", updated, @@ -231,6 +239,8 @@ public class McpServerConfigController { mcpServerConfigService.deleteConfig(id); // 增量操作:仅移除被删除的配置客户端,不影响其他已有连接 mcpClientManager.removeClient(id); + // MCP 工具清单可能变化,清空 AssistantApp 的 ChatClient 缓存,避免继续使用旧工具集 + assistantApp.clearCache(); return ResponseEntity.ok(Map.of( "success", true, "message", "配置删除成功" @@ -282,6 +292,8 @@ public class McpServerConfigController { } else { mcpClientManager.disableClient(id); } + // MCP 工具清单可能变化,清空 AssistantApp 的 ChatClient 缓存,避免继续使用旧工具集 + assistantApp.clearCache(); return ResponseEntity.ok(Map.of( "success", true, "message", active ? "配置已启用" : "配置已禁用" @@ -335,6 +347,8 @@ public class McpServerConfigController { public ResponseEntity> refreshAll() { try { mcpClientManager.refreshAll(); + // MCP 工具清单可能变化,清空 AssistantApp 的 ChatClient 缓存,避免继续使用旧工具集 + assistantApp.clearCache(); return ResponseEntity.ok(Map.of( "success", true, "message", "MCP 客户端连接已刷新" diff --git a/src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java b/src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java deleted file mode 100644 index d1ff34c..0000000 --- a/src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.wok.supportbot.document.extract; - -import lombok.extern.slf4j.Slf4j; -import org.apache.tika.Tika; -import org.apache.tika.exception.TikaException; -import org.springframework.ai.document.Document; -import org.springframework.core.io.Resource; -import org.springframework.core.io.FileSystemResource; -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; - -import java.io.File; -import java.io.IOException; -import java.util.Collections; -import java.util.List; -import java.util.UUID; - -@Component -@Slf4j -public class TikaDocumentReader { - - /** - * 从已保存的文件解析内容 - */ - public List readFromFile(File file) { - try { - Tika tika = new Tika(); - String text = tika.parseToString(new java.io.FileInputStream(file)); - - Document doc = Document.builder() - .id(UUID.randomUUID().toString()) - .text(text) - .build(); - - return Collections.singletonList(doc); - - } catch (IOException | TikaException e) { - log.error("Tika 文件解析失败", e); - throw new RuntimeException("Tika 文件解析失败", e); - } - } - - /** - * 从 MultipartFile 解析内容(兼容旧接口) - */ - public List read(MultipartFile file) { - try { - Tika tika = new Tika(); - String text = tika.parseToString(file.getInputStream()); - - Document doc = Document.builder() - .id(UUID.randomUUID().toString()) - .text(text) - .build(); - - return Collections.singletonList(doc); - - } catch (IOException | TikaException e) { - log.error("Tika 文件解析失败", e); - throw new RuntimeException("Tika 文件解析失败", e); - } - } - - /** - * 获取文件扩展名(含点号),如 ".pptx"。无扩展名时返回 ".tmp" - */ - private String getExtension(String filename) { - if (filename == null || !filename.contains(".")) { - return ".tmp"; - } - return filename.substring(filename.lastIndexOf(".")); - } -} diff --git a/src/main/java/com/wok/supportbot/document/transform/MyTokenTextSplitter.java b/src/main/java/com/wok/supportbot/document/transform/MyTokenTextSplitter.java deleted file mode 100644 index bbc7d53..0000000 --- a/src/main/java/com/wok/supportbot/document/transform/MyTokenTextSplitter.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.wok.supportbot.document.transform; - -import com.wok.supportbot.config.ChunkConfig; -import org.springframework.ai.document.Document; -import org.springframework.ai.transformer.splitter.TokenTextSplitter; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -import java.util.List; - -/** - * 自定义基于 Token 的切词器 - * 支持通过 ChunkConfig 动态调整分块参数 - */ -@Component -public class MyTokenTextSplitter { - - @Autowired - private ChunkConfig chunkConfig; - - /** - * 使用全局配置参数创建分割器 - */ - public List splitDocuments(List documents) { - TokenTextSplitter splitter = new TokenTextSplitter( - chunkConfig.getChunkSize(), - chunkConfig.getOverlap(), - chunkConfig.getMinChunkSizeChars(), - chunkConfig.getMaxNumChunks(), - chunkConfig.isKeepSeparator() - ); - return splitter.apply(documents); - } - - /** - * 使用自定义参数创建分割器(覆盖全局配置) - * - * @param documents 文档列表 - * @param chunkSize 分块大小 - * @param overlap 重叠大小 - */ - public List splitDocuments(List documents, Integer chunkSize, Integer overlap) { - int cs = chunkSize != null ? chunkSize : chunkConfig.getChunkSize(); - int ol = overlap != null ? overlap : chunkConfig.getOverlap(); - TokenTextSplitter splitter = new TokenTextSplitter( - cs, ol, - chunkConfig.getMinChunkSizeChars(), - chunkConfig.getMaxNumChunks(), - chunkConfig.isKeepSeparator() - ); - return splitter.apply(documents); - } - - /** - * 使用自定义参数创建分割器(全参数覆盖) - */ - public List splitCustomized(List documents) { - TokenTextSplitter splitter = new TokenTextSplitter( - chunkConfig.getChunkSize(), - chunkConfig.getOverlap(), - chunkConfig.getMinChunkSizeChars(), - chunkConfig.getMaxNumChunks(), - chunkConfig.isKeepSeparator() - ); - return splitter.apply(documents); - } -} diff --git a/src/main/java/com/wok/supportbot/document/transform/OverlapTokenTextSplitter.java b/src/main/java/com/wok/supportbot/document/transform/OverlapTokenTextSplitter.java new file mode 100644 index 0000000..342d374 --- /dev/null +++ b/src/main/java/com/wok/supportbot/document/transform/OverlapTokenTextSplitter.java @@ -0,0 +1,252 @@ +package com.wok.supportbot.document.transform; + +import com.knuddels.jtokkit.Encodings; +import com.knuddels.jtokkit.api.Encoding; +import com.knuddels.jtokkit.api.EncodingType; +import com.knuddels.jtokkit.api.IntArrayList; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.transformer.splitter.TextSplitter; +import org.springframework.util.Assert; + +import java.util.ArrayList; +import java.util.List; + +/** + * 带重叠(overlap)的 Token 分块器。 + * + *

      Spring AI 的 {@link org.springframework.ai.transformer.splitter.TokenTextSplitter} + * 不支持 overlap —— 它的 5 参构造器与 Builder 均无该形参(社区 PR #4054 已标记不向 1.x 回迁)。 + * 本类直接继承标准基类 {@link TextSplitter},复刻 TokenTextSplitter 的全部切分语义 + * (标点截断、最小分块长度、分块数上限、分隔符处理),仅把「前进步长」由 chunkSize + * 改为 {@code chunkSize - overlap},从而让重叠真正生效。 + * + *

      元数据继承({@code parent_document_id} / {@code chunk_index} / {@code total_chunks}) + * 由 {@link TextSplitter} 基类统一处理,本类只负责文本切分。 + * + *

      Token 编码与 TokenTextSplitter 保持一致:jtokkit + CL100K_BASE。 + */ +public class OverlapTokenTextSplitter extends TextSplitter { + + private static final Logger log = LoggerFactory.getLogger(OverlapTokenTextSplitter.class); + + /** 默认分块大小(token 数) */ + private static final int DEFAULT_CHUNK_SIZE = 800; + + /** 默认重叠 token 数(0 表示不重叠,等价于 TokenTextSplitter 行为) */ + private static final int DEFAULT_OVERLAP = 0; + + /** 默认最小分块字符数 */ + private static final int MIN_CHUNK_SIZE_CHARS = 350; + + /** 默认丢弃阈值:长度不超过该值的分块不入库 */ + private static final int MIN_CHUNK_LENGTH_TO_EMBED = 5; + + /** 默认单篇文档最大分块数 */ + private static final int MAX_NUM_CHUNKS = 10000; + + /** 默认保留分隔符 */ + private static final boolean KEEP_SEPARATOR = true; + + private final Encoding encoding = Encodings.newLazyEncodingRegistry().getEncoding(EncodingType.CL100K_BASE); + + /** 目标分块大小(token 数,不含重叠部分) */ + private final int chunkSize; + + /** 相邻分块的重叠 token 数 */ + private final int overlap; + + /** 最小分块字符数:仅当剩余 token 数超过 chunkSize 时才按标点截断,且截断点须大于该值 */ + private final int minChunkSizeChars; + + /** 丢弃长度不超过该值的分块 */ + private final int minChunkLengthToEmbed; + + /** 单篇文档最大分块数 */ + private final int maxNumChunks; + + /** 是否保留分隔符(false 时把换行替换为空格) */ + private final boolean keepSeparator; + + private OverlapTokenTextSplitter(int chunkSize, int overlap, int minChunkSizeChars, + int minChunkLengthToEmbed, int maxNumChunks, boolean keepSeparator) { + Assert.isTrue(chunkSize > 0, "chunkSize 必须大于 0"); + this.chunkSize = chunkSize; + // 重叠必须严格小于分块大小,否则前进步长会 <= 0 导致死循环;此处收敛到合法区间而非直接报错 + int clamped = Math.max(0, Math.min(overlap, chunkSize - 1)); + if (clamped != overlap) { + log.warn("overlap({}) 超出合法区间 [0, chunkSize-1={}],已收敛为 {};" + + "该配置会使前进步长退化为 1 个 token,产生大量分块,请检查 knowledge.chunk.overlap", + overlap, chunkSize - 1, clamped); + } + this.overlap = clamped; + this.minChunkSizeChars = minChunkSizeChars; + this.minChunkLengthToEmbed = minChunkLengthToEmbed; + this.maxNumChunks = maxNumChunks; + this.keepSeparator = keepSeparator; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * 标点截断允许的最小前进步长(token 数)。 + *

      + * 防止「标点截断」与「overlap」叠加后把前进步长压到 1 个 token 导致分块数爆炸: + * 截断后必须仍能前进这么多 token,否则放弃本次截断(宁可切断句子也要保证分块数量可控)。 + *

      + * overlap=0 时返回 1,即允许任意截断,与标准 {@code TokenTextSplitter} 行为完全一致。 + */ + private int minAdvance() { + if (this.overlap == 0) { + return 1; + } + return Math.max(1, (this.chunkSize - this.overlap) / 2); + } + + @Override + protected List splitText(String text) { + if (text == null || text.trim().isEmpty()) { + return new ArrayList<>(); + } + + List tokens = getEncodedTokens(text); + List chunks = new ArrayList<>(); + int numChunks = 0; + + while (!tokens.isEmpty() && numChunks < this.maxNumChunks) { + int windowSize = Math.min(this.chunkSize, tokens.size()); + boolean lastWindow = windowSize >= tokens.size(); + String chunkText = decodeTokens(tokens.subList(0, windowSize)); + + // 空白块直接跳过(不产出,也不做重叠回退) + if (chunkText.trim().isEmpty()) { + tokens = tokens.subList(windowSize, tokens.size()); + continue; + } + + // 仅当剩余 token 数超过 chunkSize 时才做标点截断,避免小文本被无谓切分 + if (tokens.size() > this.chunkSize) { + int lastPunctuation = Math.max(chunkText.lastIndexOf('.'), + Math.max(chunkText.lastIndexOf('?'), + Math.max(chunkText.lastIndexOf('!'), chunkText.lastIndexOf('\n')))); + if (lastPunctuation != -1 && lastPunctuation > this.minChunkSizeChars) { + String candidate = chunkText.substring(0, lastPunctuation + 1); + // 前进步长 = 截断后消耗的 token 数 - overlap。截断会缩短消耗量, + // 若不加约束,截断点靠前时步长会被压到 1 个 token,分块数成倍膨胀 + // (实测 chunkSize=60/overlap=30 时可达 10 倍)。此处要求截断后仍能前进至少 minAdvance()。 + if (getEncodedTokens(candidate).size() - this.overlap >= minAdvance()) { + chunkText = candidate; + } + } + } + + String chunkTextToAppend = this.keepSeparator + ? chunkText.trim() + : chunkText.replace(System.lineSeparator(), " ").trim(); + if (chunkTextToAppend.length() > this.minChunkLengthToEmbed) { + chunks.add(chunkTextToAppend); + } + numChunks++; + + // 本窗口已覆盖全部剩余 token,无需再产出重叠块 + if (lastWindow) { + tokens = new ArrayList<>(); + break; + } + + // 本块实际消耗的 token 数(可能因标点截断而少于窗口大小) + int consumed = getEncodedTokens(chunkText).size(); + // 前进步长 = 消耗量 - 重叠量;至少前进 1 个 token,避免死循环 + int step = Math.max(1, consumed - this.overlap); + tokens = tokens.subList(step, tokens.size()); + } + + // 处理剩余 token(与 TokenTextSplitter 保持一致,用于达到 maxNumChunks 上限的场景) + if (!tokens.isEmpty()) { + String remainingText = decodeTokens(tokens).replace(System.lineSeparator(), " ").trim(); + if (remainingText.length() > this.minChunkLengthToEmbed) { + chunks.add(remainingText); + } + } + + return chunks; + } + + private List getEncodedTokens(String text) { + Assert.notNull(text, "Text must not be null"); + return this.encoding.encode(text).boxed(); + } + + private String decodeTokens(List tokens) { + Assert.notNull(tokens, "Tokens must not be null"); + IntArrayList tokensIntArray = new IntArrayList(tokens.size()); + tokens.forEach(tokensIntArray::add); + return this.encoding.decode(tokensIntArray); + } + + /** + * 分块器构建器 + */ + public static final class Builder { + + private int chunkSize = DEFAULT_CHUNK_SIZE; + + private int overlap = DEFAULT_OVERLAP; + + private int minChunkSizeChars = MIN_CHUNK_SIZE_CHARS; + + private int minChunkLengthToEmbed = MIN_CHUNK_LENGTH_TO_EMBED; + + private int maxNumChunks = MAX_NUM_CHUNKS; + + private boolean keepSeparator = KEEP_SEPARATOR; + + private Builder() { + } + + /** 目标分块大小(token 数) */ + public Builder withChunkSize(int chunkSize) { + this.chunkSize = chunkSize; + return this; + } + + /** 相邻分块的重叠 token 数 */ + public Builder withOverlap(int overlap) { + this.overlap = overlap; + return this; + } + + /** 最小分块字符数(标点截断的下限) */ + public Builder withMinChunkSizeChars(int minChunkSizeChars) { + this.minChunkSizeChars = minChunkSizeChars; + return this; + } + + /** 丢弃长度不超过该值的分块 */ + public Builder withMinChunkLengthToEmbed(int minChunkLengthToEmbed) { + this.minChunkLengthToEmbed = minChunkLengthToEmbed; + return this; + } + + /** 单篇文档最大分块数 */ + public Builder withMaxNumChunks(int maxNumChunks) { + this.maxNumChunks = maxNumChunks; + return this; + } + + /** 是否保留分隔符 */ + public Builder withKeepSeparator(boolean keepSeparator) { + this.keepSeparator = keepSeparator; + return this; + } + + public OverlapTokenTextSplitter build() { + return new OverlapTokenTextSplitter(this.chunkSize, this.overlap, this.minChunkSizeChars, + this.minChunkLengthToEmbed, this.maxNumChunks, this.keepSeparator); + } + + } + +} diff --git a/src/main/java/com/wok/supportbot/mcp/McpToolCallback.java b/src/main/java/com/wok/supportbot/mcp/McpToolCallback.java index daf68b8..ce290d4 100644 --- a/src/main/java/com/wok/supportbot/mcp/McpToolCallback.java +++ b/src/main/java/com/wok/supportbot/mcp/McpToolCallback.java @@ -37,8 +37,6 @@ public class McpToolCallback implements ToolCallback { */ private static final int MAX_LOG_LENGTH = 1000; - // ==================== 工具调用事件收集 ==================== - /** * 工具调用事件记录 */ @@ -55,29 +53,6 @@ public class McpToolCallback implements ToolCallback { */ public static final String MCP_ROUNDS_KEY = "mcp_tool_rounds"; - /** - * 线程级事件收集器:在同一请求线程中收集所有工具调用事件 - * 作为无 toolContext 场景(同步调用 / 兼容旧逻辑)的兜底。 - */ - private static final ThreadLocal> EVENTS = ThreadLocal.withInitial(ArrayList::new); - - /** - * 获取当前线程收集的所有工具调用事件,并清空收集器 - */ - public static List drainEvents() { - List events = EVENTS.get(); - List copy = new ArrayList<>(events); - events.clear(); - return copy; - } - - /** - * 重置事件收集器(每个新请求开始时调用) - */ - public static void resetEvents() { - EVENTS.remove(); - } - /** * MCP Server 配置 ID(用于路由调用到正确的 MCP Client) */ @@ -184,20 +159,21 @@ public class McpToolCallback implements ToolCallback { */ @Override public String call(String toolInput) { - // 无 ToolContext 时回退到 ThreadLocal 收集器(同步调用 / 兼容旧逻辑) - return doCall(toolInput, EVENTS.get(), null); + // 接口要求的单参重载:无 ToolContext 时事件无处可传,用一次性列表承接后丢弃 + // (生产路径始终走 call(String, ToolContext),由 AssistantApp 注入收集器) + return doCall(toolInput, new ArrayList<>(), null); } /** * 带 ToolContext 的执行入口。 *

      - * 优先从 ToolContext 读取 AssistantApp 注入的事件收集器与轮次计数器, - * 解决 Reactor 流式场景下 ThreadLocal 跨线程丢失的问题;读取不到时回退 ThreadLocal。 + * 从 ToolContext 读取 AssistantApp 注入的事件收集器与轮次计数器, + * 解决 Reactor 流式场景下 ThreadLocal 跨线程丢失的问题;读取不到时事件被丢弃、轮次回退线程级计数。 */ @Override @SuppressWarnings("unchecked") public String call(String toolInput, ToolContext toolContext) { - List collector = EVENTS.get(); + List collector = new ArrayList<>(); AtomicInteger rounds = null; Map ctx = toolContext != null ? toolContext.getContext() : null; if (ctx != null) { diff --git a/src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java b/src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java deleted file mode 100644 index 814c4f9..0000000 --- a/src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.wok.supportbot.mcp; - -import org.springframework.http.codec.ServerSentEvent; - -/** - * SSE 事件构建器 - * 用于在 MCP 工具调用流程中构建标准 SSE 事件, - * 让前端能区分文本内容与工具调用状态。 - * - * 事件类型: - * - message: 普通文本内容 - * - tool_call_start: 工具调用开始 - * - tool_call_result: 工具调用结果返回 - * - error: 错误信息 - */ -public class SseEventBuilder { - - /** - * 构建普通文本消息事件 - */ - public static ServerSentEvent messageEvent(String data) { - return ServerSentEvent.builder() - .event("message") - .data(data) - .build(); - } - - /** - * 构建工具调用开始事件 - */ - public static ServerSentEvent toolCallStartEvent(String toolName, String input) { - String json = String.format("{\"tool\":\"%s\",\"input\":\"%s\"}", - escapeJson(toolName), escapeJson(input)); - return ServerSentEvent.builder() - .event("tool_call_start") - .data(json) - .build(); - } - - /** - * 构建工具调用结果事件 - */ - public static ServerSentEvent toolCallResultEvent(String toolName, String result, long latencyMs) { - String json = String.format("{\"tool\":\"%s\",\"result\":\"%s\",\"latencyMs\":%d}", - escapeJson(toolName), escapeJson(result), latencyMs); - return ServerSentEvent.builder() - .event("tool_call_result") - .data(json) - .build(); - } - - /** - * 构建错误事件 - */ - public static ServerSentEvent errorEvent(String message) { - return ServerSentEvent.builder() - .event("error") - .data("{\"message\":\"" + escapeJson(message) + "\"}") - .build(); - } - - /** - * JSON 字符串转义 - */ - private static String escapeJson(String s) { - if (s == null) return ""; - return s.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t"); - } -} diff --git a/src/main/java/com/wok/supportbot/rag/RerankerService.java b/src/main/java/com/wok/supportbot/rag/RerankerService.java index bf47e5d..715b8ad 100644 --- a/src/main/java/com/wok/supportbot/rag/RerankerService.java +++ b/src/main/java/com/wok/supportbot/rag/RerankerService.java @@ -7,8 +7,10 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.*; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.RestClient; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import java.net.http.HttpClient; import java.time.Duration; import java.util.*; import java.util.concurrent.ConcurrentHashMap; @@ -26,9 +28,29 @@ public class RerankerService { @Autowired private AiModelConfigMapper aiModelConfigMapper; - /** HTTP 超时时间(秒) */ + /** HTTP 读超时(秒):与下方 RestClient 的 read timeout 一致,超过即 fallback 到 RRF 原始排序 */ private static final int TIMEOUT_SECONDS = 3; + /** HTTP 连接超时(秒):Rerank 在检索链路内同步调用,必须快速失败 */ + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3); + + /** + * 带超时的 HTTP 客户端。 + *

      + * 原实现用 {@code new RestTemplate()}(无任何超时),慢 provider 会把检索线程拖到 TCP 超时。 + * 此处改用 RestClient + JdkClientHttpRequestFactory 显式设置 connect/read 超时。 + */ + private final RestClient restClient; + + public RerankerService() { + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .build(); + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(Duration.ofSeconds(TIMEOUT_SECONDS)); + this.restClient = RestClient.builder().requestFactory(requestFactory).build(); + } + /** RERANK 活跃配置本地缓存,避免每次 Rerank 调用都查询 ai_model_config 表 */ private volatile AiModelConfig cachedRerankConfig; private volatile long lastCacheTime = 0; @@ -132,10 +154,8 @@ public class RerankerService { headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(config.getApiKey()); - ResponseEntity response = postWithTimeout(url, headers, body); - // 解析响应:{"output":{"results":[{"index":0,"relevance_score":0.95},...]}} - Map responseBody = response.getBody(); + Map responseBody = postWithTimeout(url, headers, body); if (responseBody == null) { throw new RuntimeException("DashScope Rerank 响应为空"); } @@ -183,10 +203,8 @@ public class RerankerService { headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(config.getApiKey()); - ResponseEntity response = postWithTimeout(url, headers, body); - // 解析响应:{"results":[{"index":0,"relevance_score":0.95},...]} - Map responseBody = response.getBody(); + Map responseBody = postWithTimeout(url, headers, body); if (responseBody == null) { throw new RuntimeException("OpenAI 兼容 Rerank 响应为空"); } @@ -227,15 +245,17 @@ public class RerankerService { } /** - * 带超时的 HTTP POST 请求 + * 带 connect/read 超时的 HTTP POST 请求。 + * 使用 JDK HttpClient + JdkClientHttpRequestFactory,超时后抛异常由调用方 fallback 到 RRF 原始排序。 */ @SuppressWarnings("unchecked") - private ResponseEntity postWithTimeout(String url, HttpHeaders headers, Map body) { - RestTemplate restTemplate = new RestTemplate(); - HttpEntity> entity = new HttpEntity<>(body, headers); - // RestTemplate 默认无超时,此处依赖连接/读取超时由底层控制 - // Spring Boot 3.x 中可使用 RestClient 替代以获得更好的超时支持 - return restTemplate.exchange(url, HttpMethod.POST, entity, Map.class); + private Map postWithTimeout(String url, HttpHeaders headers, Map body) { + return restClient.post() + .uri(url) + .headers(h -> h.addAll(headers)) + .body(body) + .retrieve() + .body(Map.class); } /** diff --git a/src/main/java/com/wok/supportbot/service/DocumentProcessingService.java b/src/main/java/com/wok/supportbot/service/DocumentProcessingService.java index 89c9504..1b07c11 100644 --- a/src/main/java/com/wok/supportbot/service/DocumentProcessingService.java +++ b/src/main/java/com/wok/supportbot/service/DocumentProcessingService.java @@ -1,8 +1,9 @@ package com.wok.supportbot.service; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.wok.supportbot.config.ChunkConfig; import com.wok.supportbot.dao.KnowledgeDocumentMapper; -import com.wok.supportbot.document.transform.MyTokenTextSplitter; +import com.wok.supportbot.document.transform.OverlapTokenTextSplitter; import com.wok.supportbot.entity.KnowledgeDocument; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.document.Document; @@ -32,6 +33,12 @@ public class DocumentProcessingService { @Value("${knowledge.vector.batch-size:50}") private int embedBatchSize = 50; + /** + * 分块丢弃阈值(字符):长度不超过该值的分块不入库。 + * ChunkConfig 无对应配置项,此处沿用历史生效值(旧实现因形参错位,实际也是 10)。 + */ + private static final int MIN_CHUNK_LENGTH_TO_EMBED = 10; + /** * 文档级自动重试次数(不含首次尝试)。 * 注意:批内 EmbeddingModel 已自带 3 次指数退避重试(EmbeddingModelFactory.createRetryTemplate), @@ -50,7 +57,7 @@ public class DocumentProcessingService { private KnowledgeDocumentMapper documentMapper; @Autowired - private MyTokenTextSplitter myTokenTextSplitter; + private ChunkConfig chunkConfig; @Autowired private VectorStore pgVectorVectorStore; @@ -88,7 +95,7 @@ public class DocumentProcessingService { // 新文档首次处理无需清理(尚无向量);若中途失败触发整体重试,框架内会先清残留向量再重建 runPipelineWithRetry(doc, meta, false, - () -> myTokenTextSplitter.splitDocuments(documents, chunkSize, overlap)); + () -> buildSplitter(chunkSize, overlap).apply(documents)); } /** @@ -117,10 +124,29 @@ public class DocumentProcessingService { if (doc.getExtraConfig().get("chunkSize") instanceof Number cs) chunkSize = cs.intValue(); if (doc.getExtraConfig().get("overlap") instanceof Number ol) overlap = ol.intValue(); } - return myTokenTextSplitter.splitDocuments(documents, chunkSize, overlap); + return buildSplitter(chunkSize, overlap).apply(documents); }); } + /** + * 构建分块器:per-doc 参数优先,缺省回退全局 ChunkConfig。 + * + * @param chunkSize 分块大小(token 数,可为 null) + * @param overlap 重叠大小(token 数,可为 null) + */ + private OverlapTokenTextSplitter buildSplitter(Integer chunkSize, Integer overlap) { + int cs = chunkSize != null ? chunkSize : chunkConfig.getChunkSize(); + int ol = overlap != null ? overlap : chunkConfig.getOverlap(); + return OverlapTokenTextSplitter.builder() + .withChunkSize(cs) + .withOverlap(ol) + .withMinChunkSizeChars(chunkConfig.getMinChunkSizeChars()) + .withMinChunkLengthToEmbed(MIN_CHUNK_LENGTH_TO_EMBED) + .withMaxNumChunks(chunkConfig.getMaxNumChunks()) + .withKeepSeparator(chunkConfig.isKeepSeparator()) + .build(); + } + /** * 文档级处理框架(外层兜底):切分 → 分批向量化 → 失败自动整体重试有限次数。 * 外层 try-catch 保证任何未预期异常下文档状态都从 PROCESSING 收敛到 FAILED, diff --git a/src/main/java/com/wok/supportbot/service/DocumentService.java b/src/main/java/com/wok/supportbot/service/DocumentService.java index 5d54498..92a762c 100644 --- a/src/main/java/com/wok/supportbot/service/DocumentService.java +++ b/src/main/java/com/wok/supportbot/service/DocumentService.java @@ -6,8 +6,6 @@ import com.wok.supportbot.dao.KnowledgeDocumentMapper; import com.wok.supportbot.document.extract.JsonDocumentLoader; import com.wok.supportbot.document.extract.MarkdownDocumentLoader; import com.wok.supportbot.document.extract.SimpleStringDocumentReader; -import com.wok.supportbot.document.extract.TikaDocumentReader; -import com.wok.supportbot.document.transform.MyTokenTextSplitter; import com.wok.supportbot.entity.CategoryNode; import com.wok.supportbot.entity.KnowledgeCategory; import com.wok.supportbot.entity.KnowledgeDocument; @@ -15,10 +13,12 @@ import com.wok.supportbot.entity.KnowledgeFolder; import com.wok.supportbot.entity.SearchResult; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.document.Document; +import org.springframework.ai.reader.tika.TikaDocumentReader; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.FileSystemResource; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -52,12 +52,6 @@ public class DocumentService { @Autowired private VectorStore pgVectorVectorStore; - @Autowired - private MyTokenTextSplitter myTokenTextSplitter; - - @Autowired - private TikaDocumentReader tikaDocumentReader; - @Autowired private SimpleStringDocumentReader simpleStringDocumentReader; @@ -167,7 +161,8 @@ public class DocumentService { java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile(); // 2. 使用保存的文件进行解析(避免 MultipartFile 的临时文件问题) - List documents = tikaDocumentReader.readFromFile(savedFile); + // 使用 Spring AI 官方 TikaDocumentReader:get() 内部为 List.of(toDocument(...)),恒定返回 1 个 Document + List documents = new TikaDocumentReader(new FileSystemResource(savedFile)).get(); // 剥离路径前缀:文件夹上传时浏览器 filename 携带相对路径(含根目录名),否则标题会带「目录/」前缀 String sourceName = stripPath(file.getOriginalFilename()); String fileType = getFileExtension(sourceName); diff --git a/src/main/java/com/wok/supportbot/service/IntentRouter.java b/src/main/java/com/wok/supportbot/service/IntentRouter.java index 3c17a0d..5c9c88e 100644 --- a/src/main/java/com/wok/supportbot/service/IntentRouter.java +++ b/src/main/java/com/wok/supportbot/service/IntentRouter.java @@ -1,7 +1,5 @@ package com.wok.supportbot.service; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import com.wok.supportbot.config.ChatModelFactory; import lombok.AllArgsConstructor; import lombok.Data; @@ -9,6 +7,7 @@ import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -18,6 +17,10 @@ import org.springframework.stereotype.Service; * - FAQ: 常见问题 → FaqMatchEngine 精准匹配 * - RAG: 知识库检索 → 现有 RAG 流程 * - CHITCHAT: 闲聊 → 简单对话 + * + *

      结构化输出使用 Spring AI 标准组件 {@link BeanOutputConverter}: + * 由它把 JSON Schema 指令追加进 Prompt,并把模型返回的 JSON 反序列化为 {@link IntentResult}, + * 不再手写正则解析。{@code ChatClient.entity(...)} 内部即是同一套机制。 */ @Service @Slf4j @@ -26,15 +29,23 @@ public class IntentRouter { @Autowired private ChatModelFactory chatModelFactory; - /** 意图分类 Prompt 模板 */ + /** 结构化输出转换器(无状态,可安全复用):生成 Schema 指令 + 反序列化模型响应 */ + private static final BeanOutputConverter INTENT_CONVERTER = + new BeanOutputConverter<>(IntentResult.class); + + /** + * 意图分类 Prompt 模板。 + * 输出格式约束(JSON Schema)由 {@code BeanOutputConverter.getFormat()} 统一追加,模板中不再硬编码。 + */ private static final String INTENT_PROMPT_TEMPLATE = """ 你是一个意图分类器。根据用户问题,判断其属于以下哪个意图: - FAQ: 常见问题,如产品功能、价格、退换货政策、服务流程等标准问答 - RAG: 需要查阅文档/知识库才能回答的专业问题或细节问题 - CHITCHAT: 闲聊、问候、感谢、告别等非业务话题 - 仅返回JSON格式: {"intent":"FAQ|RAG|CHITCHAT","confidence":0.0-1.0} 用户问题: %s + + %s """; // ==================== 意图结果内部类 ==================== @@ -67,82 +78,37 @@ public class IntentRouter { try { ChatModel chatModel = chatModelFactory.getChatModel("CHAT"); - String promptText = INTENT_PROMPT_TEMPLATE.formatted(userQuestion); - Prompt prompt = new Prompt(promptText); + String promptText = INTENT_PROMPT_TEMPLATE.formatted(userQuestion, INTENT_CONVERTER.getFormat()); - String response = chatModel.call(prompt).getResult().getOutput().getText(); + String response = chatModel.call(new Prompt(promptText)).getResult().getOutput().getText(); log.debug("意图分类原始响应: {}", response); - return parseIntentResponse(response); - } catch (Exception e) { - log.error("意图分类失败,降级为 RAG: question={}", userQuestion, e); - return new IntentResult("RAG", 0.0); - } - } - - // ==================== 解析方法 ==================== - - /** 从 LLM 原始响应中提取 intent 字段值 */ - private static final Pattern INTENT_PATTERN = Pattern.compile("\"intent\"\\s*:\\s*\"(FAQ|RAG|CHITCHAT)\""); - /** 从 LLM 原始响应中提取 confidence 字段值 */ - private static final Pattern CONFIDENCE_PATTERN = Pattern.compile("\"confidence\"\\s*:\\s*(0?\\.\\d+|1\\.0|[01])"); - - /** - * 解析 LLM 返回的意图分类结果。 - * 不依赖 JSON 结构完整性,使用正则直接从原始文本提取 intent 和 confidence 字段值, - * 可正确处理 LLM 输出的各种畸变:重复拼接、缺少逗号、额外文本、BOM/零宽字符等。 - * 如果解析失败,默认返回 RAG(降级到现有流程) - */ - private IntentResult parseIntentResponse(String response) { - try { - // 1. 预处理:去除 BOM、零宽字符等不可见干扰字符 - String cleaned = response.trim() - .replace("", "") - .replace("​", "") - .replace("‌", "") - .replace("‍", "") - .replace(" ", " ") - .replace("‎", "") - .replace("‏", "") - .replace("⁠", ""); - - // 2. 去除 markdown 代码块包裹 - if (cleaned.startsWith("```")) { - cleaned = cleaned.replaceAll("^```(?:json)?\\s*", "").replaceAll("\\s*```$", ""); - } - - // 3. 正则提取 intent(取第一个匹配,应对 LLM 重复拼接 JSON 的情况) - Matcher intentMatcher = INTENT_PATTERN.matcher(cleaned); - String intent = intentMatcher.find() ? intentMatcher.group(1) : null; - - // 4. 正则提取 confidence(取第一个匹配) - Matcher confidenceMatcher = CONFIDENCE_PATTERN.matcher(cleaned); - double confidence = confidenceMatcher.find() ? Double.parseDouble(confidenceMatcher.group(1)) : 0.5; - - if (intent == null) { - log.warn("未能从 LLM 响应中提取到 intent 字段,降级为 RAG"); - return new IntentResult("RAG", 0.0); - } - - if (!isValidIntent(intent)) { - log.warn("无效的意图类型: {}, 降级为 RAG", intent); + IntentResult result = INTENT_CONVERTER.convert(response); + if (result == null || !isValidIntent(result.getIntent())) { + log.warn("意图分类结果无效,降级为 RAG: rawResponse={}", abbreviate(response)); return new IntentResult("RAG", 0.5); } - - return new IntentResult(intent, confidence); + return result; } catch (Exception e) { - String truncated = response != null && response.length() > 200 - ? response.substring(0, 200) + "..." - : response; - log.warn("意图分类解析异常,降级为 RAG: rawResponse={}", truncated, e); + log.warn("意图分类失败,降级为 RAG: question={}", abbreviate(userQuestion), e); return new IntentResult("RAG", 0.0); } } /** - * 校验意图类型是否有效 + * 校验意图类型是否有效(模型可能返回枚举外的值) */ private boolean isValidIntent(String intent) { return "FAQ".equals(intent) || "RAG".equals(intent) || "CHITCHAT".equals(intent); } + + /** + * 日志截断,避免回显整段响应 + */ + private static String abbreviate(String text) { + if (text == null) { + return null; + } + return text.length() > 200 ? text.substring(0, 200) + "..." : text; + } } From 94c1a074e7f69069f571848b934b50b7ea7cbe16 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Thu, 10 Sep 2026 17:33:04 +0800 Subject: [PATCH 36/39] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/static/sdk/test.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/static/sdk/test.html b/src/main/resources/static/sdk/test.html index 6bae2f0..14af902 100644 --- a/src/main/resources/static/sdk/test.html +++ b/src/main/resources/static/sdk/test.html @@ -11,8 +11,8 @@ - + From 2001dc3a0fce7ce0eacf7234d0b1b051f83eceb0 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Fri, 11 Sep 2026 08:37:28 +0800 Subject: [PATCH 37/39] =?UTF-8?q?fix(doc):=20=E4=BF=AE=E5=A4=8D=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E5=A4=84=E7=90=86=E6=96=87=E6=A1=A3=E4=BC=9A=E6=8C=89?= =?UTF-8?q?=202000=20=E5=AD=97=E6=88=AA=E6=96=AD=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E9=87=8D=E5=BB=BA=E5=AF=BC=E8=87=B4=E5=86=85=E5=AE=B9=E4=B8=A2?= =?UTF-8?q?=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题 - knowledge_document.content 上传时被截断到 2000 字符(有意的预览设计), 但 reprocessDocument 把它当原文重建分块,而 DocumentProcessingService 以 cleanBeforeFirstAttempt=true 运行(先删光旧向量)——对超过 2000 字符的文档 执行 POST /document/batch/reprocess 会永久丢失其余内容 修复 - content 改存原文全文(列已是 TEXT,无需 DDL),并写 extra_config.contentComplete 作为完整性标记 - 实体 content 加 @JsonIgnore:全文任何接口都不返回(否则 GET /document/list 无字段投影,每行都会带上整篇正文);文档详情接口单独组装响应,返回 2000 字预览 (键名仍是 content)与 contentTruncated 标志,前端据此提示截断 - reprocessDocument 的数据源按优先级解析: 1) 有 contentComplete 标记 → 用库内全文(语义最忠实,JSON 的 fields/pointer 模式不受影响,也不依赖文件是否还在) 2) 历史遗留文档(无标记,content 是截断预览)→ 按 fileType 从原始文件重解析 (md → MarkdownDocumentLoader,json → JsonDocumentLoader,其余 → Tika), 成功后回填全文并打标记,此后不再依赖原始文件 3) 两者都不可用 → 明确报错拒绝,绝不静默按截断预览重建 无法完整还原时在标记 PROCESSING 之前抛错,事务回滚,文档状态与已有向量都不受影响 - contentTruncated 判据覆盖历史数据:长度恰好等于上限且无 contentComplete 标记时 同样判定为已截断,避免把截断预览误报成完整内容 - 同步 content 列注释(DatabaseInitConfig / init-database.sql / knowledge-base.sql), 该注释会在下次启动幂等刷新到库 验证(真实库 + 557 篇存量文档环境) - 上传 4891 字符正文:库内存全文,详情接口返回 2000 字预览 + contentTruncated=true - 重新处理:chunk_count 与首次完全一致(修复前会降为个位数),内容无丢失 - 模拟历史数据(截断 + 无标记):有文件时从文件重解析成功并回填全文; 文件也丢失时明确拒绝,且文档状态与 chunk_count 保持不变(向量未被破坏) - 回填后再删文件重跑仍成功(已自愈);GET /document/list 不再含 content - RAG 对话回归正常 已知降级 - JSON 的 basic/fields/pointer 解析模式上传时未持久化,历史 JSON 文档从文件重解析 只能按 basic 还原(不丢数据,仅抽取口径可能变化,日志有 WARN) - 存量中疑似被截断的文档需重跑一次才能回填全文(均已保留原始文件,均可恢复) --- CLAUDE.md | 12 +++ README.md | 2 +- frontend/src/views/DocDetail.vue | 2 +- .../supportbot/config/DatabaseInitConfig.java | 2 +- .../controller/DocumentController.java | 31 ++++++- .../supportbot/entity/KnowledgeDocument.java | 8 +- .../supportbot/service/DocumentService.java | 87 +++++++++++++++++-- src/main/resources/init-database.sql | 2 +- src/main/resources/knowledge-base.sql | 2 +- 9 files changed, 135 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 87558f0..5e36709 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,18 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支 **向量化加固**(`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)。新文档走库内全文分支,语义不变。 + ## 关键配置 diff --git a/README.md b/README.md index c475ef0..8680556 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ CREATE TABLE knowledge_document ( source_name VARCHAR(500), -- 原始文件名 file_type VARCHAR(20) NOT NULL, -- 文件类型 file_size BIGINT DEFAULT 0, -- 文件大小(字节) - content TEXT, -- 原文内容(截断预览) + content TEXT, -- 原文全文(重处理用;接口仅返回 2000 字预览) category_id BIGINT DEFAULT 0, -- 所属分类ID tags JSONB DEFAULT '{}', -- 标签(JSON对象) chunk_count INTEGER DEFAULT 0, -- 分块数量 diff --git a/frontend/src/views/DocDetail.vue b/frontend/src/views/DocDetail.vue index d3be730..6ba682d 100644 --- a/frontend/src/views/DocDetail.vue +++ b/frontend/src/views/DocDetail.vue @@ -26,7 +26,7 @@

      {{ doc.sourceName || doc.title || '原文' }} - 内容已截断(仅显示前2000字符) + 内容已截断(仅显示前2000字符)
      {{ doc.content }}
      diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index 4e69124..9e6afe3 100644 --- a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java +++ b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java @@ -1500,7 +1500,7 @@ public class DatabaseInitConfig { executeComment("COLUMN knowledge_document.source_name", "原始文件名"); executeComment("COLUMN knowledge_document.file_type", "文件类型: pdf / md / json / txt / word / excel 等"); executeComment("COLUMN knowledge_document.file_size", "文件大小(字节)"); - executeComment("COLUMN knowledge_document.content", "原文内容(截断预览)"); + executeComment("COLUMN knowledge_document.content", "原文全文(用于重新处理时重建分块;接口仅返回 2000 字预览)"); executeComment("COLUMN knowledge_document.category_id", "所属分类 ID(0 表示未分类)"); executeComment("COLUMN knowledge_document.folder_id", "所属目录 ID(0 表示未指定目录,直接挂分类根)"); executeComment("COLUMN knowledge_document.tags", "标签(JSON 格式)"); diff --git a/src/main/java/com/wok/supportbot/controller/DocumentController.java b/src/main/java/com/wok/supportbot/controller/DocumentController.java index 8e61063..938797a 100644 --- a/src/main/java/com/wok/supportbot/controller/DocumentController.java +++ b/src/main/java/com/wok/supportbot/controller/DocumentController.java @@ -1,5 +1,7 @@ package com.wok.supportbot.controller; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.wok.supportbot.entity.CategoryNode; import com.wok.supportbot.entity.KnowledgeCategory; import com.wok.supportbot.entity.KnowledgeDocument; @@ -30,6 +32,11 @@ public class DocumentController { private DocumentService documentService; @Autowired private CategoryFilter categoryFilter; + @Autowired + private ObjectMapper objectMapper; + + /** 文档详情接口返回的原文预览上限(字符)。仅影响传输展示,不影响存库与重新处理 */ + private static final int CONTENT_PREVIEW_LIMIT = 2000; // ==================== 上传校验常量 ==================== @@ -384,7 +391,7 @@ public class DocumentController { } return ResponseEntity.ok(Map.of( "success", true, - "data", doc + "data", toDetailMap(doc) )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( @@ -394,6 +401,28 @@ public class DocumentController { } } + /** + * 组装文档详情响应。 + *

      + * 实体上的 {@code content} 带 {@code @JsonIgnore}(存的是全文,可达数十万字符,不能直接返回给前端), + * 此处显式补回键名仍为 {@code content} 的**截断预览**,并附 {@code contentTruncated} 标志供前端提示。 + * 预览仅用于展示,与存库内容、与重新处理的数据源无关。 + */ + private Map toDetailMap(KnowledgeDocument doc) { + Map data = objectMapper.convertValue(doc, new TypeReference>() {}); + String content = doc.getContent(); + boolean contentComplete = doc.getExtraConfig() != null + && Boolean.TRUE.equals(doc.getExtraConfig().get("contentComplete")); + // 历史遗留文档没有 contentComplete 标记,其 content 是 2000 字截断预览; + // 长度恰好等于上限时也必须提示已截断,否则会把截断预览误报成完整内容 + boolean truncated = content != null + && (content.length() > CONTENT_PREVIEW_LIMIT + || (content.length() == CONTENT_PREVIEW_LIMIT && !contentComplete)); + data.put("content", truncated ? content.substring(0, CONTENT_PREVIEW_LIMIT) : content); + data.put("contentTruncated", truncated); + return data; + } + /** * 获取文档的所有分块 */ diff --git a/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java b/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java index f6653f9..ca65dbc 100644 --- a/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java +++ b/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java @@ -1,6 +1,7 @@ package com.wok.supportbot.entity; import com.baomidou.mybatisplus.annotation.*; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import com.wok.supportbot.handler.PostgresJsonTypeHandler; @@ -63,9 +64,14 @@ public class KnowledgeDocument implements Serializable { private String filePath; /** - * 原文内容(截断预览) + * 原文全文(用于重新处理时重建分块)。 + *

      + * {@code @JsonIgnore}:全文可达数十万字符,**任何接口都不序列化**,避免列表/回显接口携带整篇正文。 + * 文档详情接口(DocumentController.getDocumentDetail)单独组装响应,返回截断后的预览 + * (键名仍为 {@code content})与 {@code contentTruncated} 标志。 */ @TableField("content") + @JsonIgnore private String content; /** diff --git a/src/main/java/com/wok/supportbot/service/DocumentService.java b/src/main/java/com/wok/supportbot/service/DocumentService.java index 92a762c..da5df8e 100644 --- a/src/main/java/com/wok/supportbot/service/DocumentService.java +++ b/src/main/java/com/wok/supportbot/service/DocumentService.java @@ -22,6 +22,7 @@ import org.springframework.core.io.FileSystemResource; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import com.fasterxml.jackson.databind.ObjectMapper; @@ -122,6 +123,9 @@ public class DocumentService { Map extraConfig = new HashMap<>(); if (chunkSize != null) extraConfig.put("chunkSize", chunkSize); if (overlap != null) extraConfig.put("overlap", overlap); + // 完整性标记:本字段起 content 存全文(不再截断),可安全用于 reprocess。 + // 历史数据无此标记(其 content 是 2000 字截断预览),reprocess 据此走「从原始文件重解析」或明确拒绝。 + extraConfig.put("contentComplete", true); // 2. 创建文档记录(状态 PROCESSING) KnowledgeDocument docRecord = KnowledgeDocument.builder() @@ -130,13 +134,13 @@ public class DocumentService { .fileType(fileType) .fileSize(fileSize != null ? fileSize : 0L) .filePath(filePath) - .content(content != null && content.length() > 2000 ? content.substring(0, 2000) : content) + .content(content) .categoryId(categoryId != null ? categoryId : 0L) .folderId(folderId != null ? folderId : 0L) .tags(tags != null ? Map.of("tags", tags) : null) .contentHash(contentHash) .enabled(true) - .extraConfig(extraConfig.isEmpty() ? null : extraConfig) + .extraConfig(extraConfig) .status("PROCESSING") .chunkCount(0) .build(); @@ -663,6 +667,9 @@ public class DocumentService { /** * 重新处理文档(异步:先标记为 PROCESSING,后台重新分块 + 向量化) + *

      + * 数据源解析见 {@link #loadSourceDocuments}:优先用库内全文,历史遗留文档回退到原始文件重解析, + * 两者都不可用时明确报错拒绝 —— 绝不静默按截断预览重建(会先删旧向量,属于不可逆的数据丢失)。 */ @Transactional(rollbackFor = Exception.class) public KnowledgeDocument reprocessDocument(Long id) { @@ -670,8 +677,20 @@ public class DocumentService { if (doc == null) { throw new RuntimeException("文档不存在"); } - if (doc.getContent() == null || doc.getContent().isEmpty()) { - throw new RuntimeException("文档无内容,无法重新处理"); + + // 先解析数据源:无法完整还原时直接抛错(事务回滚),不会破坏已有向量与文档状态 + SourceLoad loaded = loadSourceDocuments(doc); + List documents = loaded.documents(); + + // 历史遗留文档从原始文件重解析成功后,把全文回填进 content 并打上完整性标记: + // 该文档此后不再依赖原始文件(文件丢失也能重跑),详情接口的 contentTruncated 也恢复准确 + if (loaded.reparsedFromFile()) { + doc.setContent(documents.stream().map(Document::getText).collect(Collectors.joining("\n"))); + Map extra = doc.getExtraConfig() != null + ? new HashMap<>(doc.getExtraConfig()) + : new HashMap<>(); + extra.put("contentComplete", true); + doc.setExtraConfig(extra); } // 标记为 PROCESSING,清空旧状态 @@ -680,14 +699,70 @@ public class DocumentService { doc.setErrorMessage(null); documentMapper.updateById(doc); - // 解析内容并触发异步处理 - List documents = simpleStringDocumentReader.read(doc.getContent()); + // 触发异步处理 documentProcessingService.reprocessDocumentAsync(id, documents); log.info("文档重新处理已提交: id={}, title={}", doc.getId(), doc.getTitle()); return doc; } + /** 重新处理的数据源解析结果 */ + private record SourceLoad(List documents, boolean reparsedFromFile) {} + + /** + * 解析重新处理所需的数据源。 + *

        + *
      1. 库内全文优先:新文档 content 存的是全文(含 extraConfig.contentComplete 标记), + * 用它可保持与首次上传完全一致的抽取语义(例如 JSON 的 fields/pointer 模式),且不依赖文件是否还在。
      2. + *
      3. 回退原始文件:历史遗留文档的 content 是 2000 字截断预览,此时按 fileType 重新解析原始文件。
      4. + *
      5. 明确拒绝:两者都不可用时抛错,绝不静默用截断预览重建(reprocess 会先删旧向量,属不可逆丢失)。
      6. + *
      + */ + private SourceLoad loadSourceDocuments(KnowledgeDocument doc) { + String content = doc.getContent(); + boolean contentComplete = doc.getExtraConfig() != null + && Boolean.TRUE.equals(doc.getExtraConfig().get("contentComplete")); + + // 1) 库内全文优先 + if (contentComplete && StringUtils.hasText(content)) { + return new SourceLoad(simpleStringDocumentReader.read(content), false); + } + + // 2) 历史遗留文档:从原始文件按 fileType 重解析 + if (StringUtils.hasText(doc.getFilePath())) { + java.io.File file = fileStorageConfig.getFilePath(doc.getFilePath()).toFile(); + if (file.exists()) { + log.warn("库内 content 为历史截断数据,改为从原始文件重解析: id={}, fileType={}, path={}", + doc.getId(), doc.getFileType(), doc.getFilePath()); + return new SourceLoad(parseFromFile(doc.getFileType(), file), true); + } + log.warn("原始文件不存在,无法完整重解析: id={}, path={}", doc.getId(), doc.getFilePath()); + } + + // 3) 无法完整还原 + throw new RuntimeException("该文档为历史遗留数据(库内仅存截断预览,原始文件也已丢失)," + + "无法完整重新处理,请重新上传该文档"); + } + + /** + * 按上传时的文件类型选择读取器,与各 uploadXxx 保持同一条解析路径。 + *

      + * 注意 fileType 是原始扩展名(pdf/docx/csv/html/…),故用 default 兜到 Tika,仅 md/json 单独分支。 + * 已知降级:JSON 的三种解析模式(basic/fields/pointer)上传时未持久化,历史文档只能按 basic 还原 + * (不丢数据,仅抽取口径可能变化),日志会 WARN 提示。 + */ + private List parseFromFile(String fileType, java.io.File file) { + String type = fileType == null ? "" : fileType.toLowerCase(); + return switch (type) { + case "md", "markdown" -> markdownDocumentLoader.loadMarkdownFromFile(file); + case "json" -> { + log.warn("历史 JSON 文档未持久化解析模式(basic/fields/pointer),按 basic 重解析: {}", file.getName()); + yield jsonDocumentLoader.loadBasicJsonFromFile(file); + } + default -> new TikaDocumentReader(new FileSystemResource(file)).get(); + }; + } + /** * 更新文档元信息 */ diff --git a/src/main/resources/init-database.sql b/src/main/resources/init-database.sql index 4806813..a54be88 100644 --- a/src/main/resources/init-database.sql +++ b/src/main/resources/init-database.sql @@ -154,7 +154,7 @@ COMMENT ON COLUMN knowledge_document.source_name IS '原始文件名'; COMMENT ON COLUMN knowledge_document.file_type IS '文件类型'; COMMENT ON COLUMN knowledge_document.file_size IS '文件大小(字节)'; COMMENT ON COLUMN knowledge_document.file_path IS '原始文件存储路径(相对路径)'; -COMMENT ON COLUMN knowledge_document.content IS '原文内容(截断预览)'; +COMMENT ON COLUMN knowledge_document.content IS '原文全文(用于重新处理时重建分块;接口仅返回 2000 字预览)'; COMMENT ON COLUMN knowledge_document.category_id IS '所属分类ID'; COMMENT ON COLUMN knowledge_document.folder_id IS '所属目录ID(0 表示未指定目录,直接挂分类根)'; COMMENT ON COLUMN knowledge_document.tags IS '标签(JSON)'; diff --git a/src/main/resources/knowledge-base.sql b/src/main/resources/knowledge-base.sql index 2436dfe..5ffff21 100644 --- a/src/main/resources/knowledge-base.sql +++ b/src/main/resources/knowledge-base.sql @@ -64,7 +64,7 @@ COMMENT ON COLUMN knowledge_document.title IS '文档标题'; COMMENT ON COLUMN knowledge_document.source_name IS '原始文件名'; COMMENT ON COLUMN knowledge_document.file_type IS '文件类型 - pdf/md/json/txt/word/excel 等'; COMMENT ON COLUMN knowledge_document.file_size IS '文件大小(字节)'; -COMMENT ON COLUMN knowledge_document.content IS '原文内容(截断预览)'; +COMMENT ON COLUMN knowledge_document.content IS '原文全文(用于重新处理时重建分块;接口仅返回 2000 字预览)'; COMMENT ON COLUMN knowledge_document.category_id IS '所属分类ID - 0表示未分类'; COMMENT ON COLUMN knowledge_document.tags IS '标签列表(JSON数组)'; COMMENT ON COLUMN knowledge_document.chunk_count IS '分块数量'; From 0838dca61a79bc102e7cd091f998d4084df485a4 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Fri, 11 Sep 2026 09:25:45 +0800 Subject: [PATCH 38/39] =?UTF-8?q?fix(auth):=20=E4=BF=AE=E5=A4=8D=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E4=BC=9A=E8=AF=9D=E9=95=BF=E6=9C=9F=E4=B8=8D=E5=A4=B1?= =?UTF-8?q?=E6=95=88=EF=BC=8CJWT=20=E6=97=B6=E9=97=B4=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E5=8F=AF=E8=AF=BB=E5=8D=95=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题 - 关闭后台页面数天后重新打开,可跳过登录页直接进入后台 - 根因:refresh token 有效期 7 天且滚动续期无上限;refresh Cookie 为持久化 Cookie (maxAge 硬编码 7 天)在浏览器关闭后不会删除,重开页面时 App.vue 启动自检发现 /auth/me 返回 401,静默调 /auth/refresh 换发新 token 并重置窗口 —— 只要 7 天内 来访一次即等效永久会话 会话有效期修复 - application.yml:refresh-expiration 由 7 天改为 8h,作为会话时长的唯一配置项, 所有环境统一生效(不在 dev/prod 覆盖) - auth/AuthController:refresh Cookie 的 Max-Age 由硬编码 7*24*60*60 改为读 jwt.refresh-expiration,消除「改配置不改 Cookie」造成的有效期漂移 - App.vue:刷新 token 成功后二次校验 /auth/me,失败即登出;修复原先 .catch(() => {}) 丢弃返回值导致用户带着空 currentUser 进入后台、权限判断全部 失效、直到某个请求 401 才被踢出的漏洞 JWT 时间配置改用 Duration 可读格式 - expiration / refresh-expiration / sdk-expiration 均绑定为 java.time.Duration, 值写成 15m / 8h / 2h;纯数字仍按毫秒解析,向后兼容旧配置 - JwtTokenProvider 兜底默认值由 604800000(7 天,与 yml 的 8 小时并不一致)修正为 8h - SdkJwtTokenProvider:默认有效期改 Duration,钳制边界 MIN/MAX_EXPIRATION 改为 Duration.ofMinutes(5) / Duration.ofDays(1),并公开 clampExpirationMillis 供控制器复用 SDK 配置接通 - controller/AuthController(SDK):默认 ttl 改读 jwt.sdk-expiration。此前硬编码 7200000L,而唯一读该配置的 3 参 generateToken 无任何调用方,属改了不生效的死配置 - 控制器内不再出现 300000L / 86400000L 等毫秒魔数,expiresIn 与 token 实际有效期 保持一致(对外的 ttl / expiresIn 单位仍为秒,契约不变) 验证 - 实测 Duration 绑定链路:15m→PT15M(900s)、8h→PT8H(28800s)、2h→PT2H(7200s), 纯数字 28800000→PT8H(28800s),确认向后兼容 - mvn clean package -P prod 构建通过(含前端构建) 文档 - CLAUDE.md 同步会话有效期约定、Duration 单位规范、SDK Token 有效期约定 已知残留 - 仍保留滚动续期,页面持续活跃的用户不会掉线;彻底杜绝需引入绝对过期上限或服务端 落库记录最后活动时间,本次未做 --- CLAUDE.md | 5 ++++ frontend/src/App.vue | 9 ++++-- .../wok/supportbot/auth/AuthController.java | 13 +++++++-- .../supportbot/controller/AuthController.java | 12 ++++---- .../supportbot/security/JwtTokenProvider.java | 21 ++++++++++---- .../security/SdkJwtTokenProvider.java | 28 +++++++++++++------ src/main/resources/application.yml | 15 ++++++---- 7 files changed, 72 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5e36709..dd85b7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,11 @@ 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 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2c75068..28a2da7 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -33,10 +33,15 @@ onMounted(async () => { if (isLoggedIn()) { const ok = await auth.fetchUser() if (!ok) { - // 尝试刷新 Token + // access token 已过期,尝试用 refresh cookie 换取新 token const refreshed = await auth.tryRefreshToken() if (refreshed) { - await auth.fetchUser().catch(() => {}) + // 刷新成功后必须再校验一次用户信息:/auth/me 仍失败说明账号已被禁用等, + // 此时不能放行进后台(否则 currentUser 为空、权限判断全部失效) + const okAfterRefresh = await auth.fetchUser() + if (!okAfterRefresh) { + await auth.doLogout() + } } else { await auth.doLogout() } diff --git a/src/main/java/com/wok/supportbot/auth/AuthController.java b/src/main/java/com/wok/supportbot/auth/AuthController.java index 66d9c9e..0871fa9 100644 --- a/src/main/java/com/wok/supportbot/auth/AuthController.java +++ b/src/main/java/com/wok/supportbot/auth/AuthController.java @@ -103,7 +103,7 @@ public class AuthController { loginAttemptService.recordSuccess(username); Map userInfo = buildUserInfo(user, roleKeys); - ResponseCookie refreshCookie = buildRefreshCookie(refreshToken, 7 * 24 * 60 * 60L, request.isSecure()); + ResponseCookie refreshCookie = buildRefreshCookie(refreshToken, refreshCookieMaxAgeSeconds(), request.isSecure()); log.info("用户登录成功: {}", username); return ResponseEntity.ok() @@ -164,7 +164,7 @@ public class AuthController { String newAccessToken = jwtTokenProvider.generateToken(username, roleKeys, currentVer); String newRefreshToken = jwtTokenProvider.generateRefreshToken(username, currentVer); - ResponseCookie refreshCookie = buildRefreshCookie(newRefreshToken, 7 * 24 * 60 * 60L, request.isSecure()); + ResponseCookie refreshCookie = buildRefreshCookie(newRefreshToken, refreshCookieMaxAgeSeconds(), request.isSecure()); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) @@ -279,6 +279,15 @@ public class AuthController { return userInfo; } + /** + * refresh cookie 的 Max-Age(秒) + * 直接取自 jwt.refresh-expiration 配置,保证 Cookie 生命周期与 refresh token 完全一致, + * 避免两处硬编码后「改配置不改 Cookie」导致的有效期漂移 + */ + private long refreshCookieMaxAgeSeconds() { + return jwtTokenProvider.getRefreshExpirationSeconds(); + } + /** * 构建 refresh token Cookie。 * Secure 属性按「配置开关 && 当前请求是否 HTTPS」动态决定: diff --git a/src/main/java/com/wok/supportbot/controller/AuthController.java b/src/main/java/com/wok/supportbot/controller/AuthController.java index e20f60e..9df2cd8 100644 --- a/src/main/java/com/wok/supportbot/controller/AuthController.java +++ b/src/main/java/com/wok/supportbot/controller/AuthController.java @@ -88,12 +88,12 @@ public class AuthController { .map(RoleBrief::id) .collect(Collectors.toList()); - // 4. 计算过期时间(默认 2 小时,钳制到 [5min, 24h]) - long ttlMs = 7200000L; - if (body != null && body.ttl() != null) { - ttlMs = Math.max(body.ttl() * 1000L, 300000L); - ttlMs = Math.min(ttlMs, 86400000L); - } + // 4. 计算过期时间:调用方指定 ttl(秒)则用指定值,否则取 jwt.sdk-expiration 配置的默认值 + // 再统一按 [5min, 24h] 钳制,保证返回的 expiresIn 与 token 实际有效期一致 + long ttlMs = (body != null && body.ttl() != null) + ? body.ttl() * 1000L + : sdkJwtTokenProvider.getDefaultExpirationMillis(); + ttlMs = sdkJwtTokenProvider.clampExpirationMillis(ttlMs); // 5. 签发 SDK JWT(subject = apiKeyId) String token = sdkJwtTokenProvider.generateToken( diff --git a/src/main/java/com/wok/supportbot/security/JwtTokenProvider.java b/src/main/java/com/wok/supportbot/security/JwtTokenProvider.java index 95d5039..70149ab 100644 --- a/src/main/java/com/wok/supportbot/security/JwtTokenProvider.java +++ b/src/main/java/com/wok/supportbot/security/JwtTokenProvider.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Date; import java.util.List; @@ -25,14 +26,14 @@ public class JwtTokenProvider { public static final String DEFAULT_SECRET = "support-bot-jwt-secret-key-2026-please-change-in-production"; private final SecretKey key; - private final long expiration; - private final long refreshExpiration; + private final Duration expiration; + private final Duration refreshExpiration; private final boolean failIfDefaultSecret; public JwtTokenProvider( @Value("${jwt.secret}") String secret, - @Value("${jwt.expiration:900000}") long expiration, - @Value("${jwt.refresh-expiration:604800000}") long refreshExpiration, + @Value("${jwt.expiration:15m}") Duration expiration, + @Value("${jwt.refresh-expiration:8h}") Duration refreshExpiration, @Value("${jwt.fail-if-default-secret:false}") boolean failIfDefaultSecret) { if (DEFAULT_SECRET.equals(secret)) { String message = "⚠️ 管理后台 JWT 使用了默认密钥,存在安全风险!请在 application.yml 中配置 jwt.secret 为强随机字符串(建议通过环境变量 JWT_SECRET 注入)"; @@ -63,7 +64,7 @@ public class JwtTokenProvider { .claim("type", "access") .claim("ver", tokenVersion) .issuedAt(now) - .expiration(new Date(now.getTime() + expiration)) + .expiration(new Date(now.getTime() + expiration.toMillis())) .signWith(key) .compact(); } @@ -78,11 +79,19 @@ public class JwtTokenProvider { .claim("type", "refresh") .claim("ver", tokenVersion) .issuedAt(now) - .expiration(new Date(now.getTime() + refreshExpiration)) + .expiration(new Date(now.getTime() + refreshExpiration.toMillis())) .signWith(key) .compact(); } + /** + * 刷新令牌有效期(秒) + * 供 refresh Cookie 的 Max-Age 复用,保证 Cookie 与 refresh token 的生命周期始终一致 + */ + public long getRefreshExpirationSeconds() { + return refreshExpiration.toSeconds(); + } + /** * 验证 access token 有效性(校验签名、过期时间,且 type 必须为 access) */ diff --git a/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java b/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java index 164a9fb..7350a60 100644 --- a/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java +++ b/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java @@ -12,6 +12,7 @@ import org.springframework.util.StringUtils; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Date; import java.util.List; import java.util.Set; @@ -31,19 +32,19 @@ import java.util.stream.Collectors; public class SdkJwtTokenProvider { private final SecretKey key; - private final long defaultExpiration; + private final Duration defaultExpiration; /** 24 小时上限 */ - private static final long MAX_EXPIRATION = 86400000L; + private static final Duration MAX_EXPIRATION = Duration.ofDays(1); /** 5 分钟下限 */ - private static final long MIN_EXPIRATION = 300000L; + private static final Duration MIN_EXPIRATION = Duration.ofMinutes(5); /** 默认密钥标识(禁止使用),长度 ≥64 字符以兼容 HS256/HS384/HS512,且必须与 application.yml 默认值一致 */ private static final String DEFAULT_SECRET = "support-bot-sdk-jwt-secret-2026-please-change"; public SdkJwtTokenProvider( @Value("${jwt.sdk-secret:support-bot-sdk-jwt-secret-2026-please-change}") String sdkSecret, - @Value("${jwt.sdk-expiration:7200000}") long defaultExpiration, + @Value("${jwt.sdk-expiration:2h}") Duration defaultExpiration, @Value("${jwt.fail-if-default-sdk-secret:false}") boolean failIfDefaultSdkSecret) { // 空值校验:密钥为空/空白时启动失败并给出明确提示,避免 Keys.hmacShaKeyFor 抛出晦涩异常 if (!StringUtils.hasText(sdkSecret)) { @@ -70,7 +71,7 @@ public class SdkJwtTokenProvider { * @return JWT Token 字符串 */ public String generateToken(String apiKeyId, String maskedApiKey, List roleIds, long expirationMs) { - long clampedExpiration = clampExpiration(expirationMs); + long clampedExpiration = clampExpirationMillis(expirationMs); Date now = new Date(); return Jwts.builder() .subject(apiKeyId) @@ -86,7 +87,15 @@ public class SdkJwtTokenProvider { * 使用默认过期时间签发 SDK JWT Token */ public String generateToken(String apiKeyId, String maskedApiKey, List roleIds) { - return generateToken(apiKeyId, maskedApiKey, roleIds, defaultExpiration); + return generateToken(apiKeyId, maskedApiKey, roleIds, defaultExpiration.toMillis()); + } + + /** + * 默认有效期(毫秒),取自 jwt.sdk-expiration 配置 + * 供 SDK 版 AuthController 在调用方未指定 ttl 时作为默认值使用 + */ + public long getDefaultExpirationMillis() { + return defaultExpiration.toMillis(); } /** @@ -151,9 +160,10 @@ public class SdkJwtTokenProvider { } /** - * 将过期时间钳制到 [5min, 24h] 范围内 + * 将过期时间钳制到 [5min, 24h] 范围内(毫秒) + * 公开供 SDK 版 AuthController 复用,使钳制边界只有一处定义,避免响应值与 token 实际有效期不一致 */ - private long clampExpiration(long expirationMs) { - return Math.max(MIN_EXPIRATION, Math.min(expirationMs, MAX_EXPIRATION)); + public long clampExpirationMillis(long expirationMs) { + return Math.max(MIN_EXPIRATION.toMillis(), Math.min(expirationMs, MAX_EXPIRATION.toMillis())); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8172a20..86aa47d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -131,10 +131,13 @@ logging: jwt: # 签名密钥(生产环境务必通过环境变量 JWT_SECRET 覆盖) secret: ${JWT_SECRET:support-bot-jwt-secret-key-2026-please-change-in-production} - # 访问令牌有效期:15分钟(毫秒),降低 XSS 窃取后的利用窗口 - expiration: 900000 - # 刷新令牌有效期:7天(毫秒) - refresh-expiration: 604800000 + # 访问令牌有效期:15分钟,降低 XSS 窃取后的利用窗口 + # 单位写法:支持 15m / 900s / 900000ms 等可读格式;纯数字按毫秒解析(向后兼容旧配置) + expiration: 15m + # 刷新令牌有效期:8小时。关闭页面超过该时长后重新打开需重新登录。 + # 这是会话时长的唯一配置项,所有环境统一生效;refresh Cookie 的 Max-Age 由 AuthController 读取本项自动跟随,不得硬编码。 + # 注意:保留滚动续期 —— 页面持续活跃的用户每 15 分钟会静默刷新一次,窗口随之重置,不会掉线。 + refresh-expiration: 8h # refresh token cookie 是否标记 Secure(作为总开关;实际 Secure 属性由 AuthController 按请求是否 HTTPS 动态决定,HTTP 自动降级为 false) refresh-cookie-secure: true # 使用默认密钥时是否直接启动失败(生产建议 true) @@ -144,8 +147,8 @@ jwt: sdk-secret: ${JWT_SDK_SECRET:support-bot-sdk-jwt-secret-2026-please-change} # 使用默认 SDK 密钥时是否直接启动失败(生产建议 true) fail-if-default-sdk-secret: false - # SDK Token 默认有效期:2小时(毫秒) - sdk-expiration: 7200000 + # SDK Token 默认有效期:2小时,作为 SDK 换 Token 接口未指定 ttl 时的默认值 + sdk-expiration: 2h # ==================== 登录安全配置 ==================== security: From d33cb56e8ace39ac58238e03f5686bb064c13708 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Fri, 11 Sep 2026 15:26:21 +0800 Subject: [PATCH 39/39] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/static/sdk/test.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/static/sdk/test.html b/src/main/resources/static/sdk/test.html index 14af902..6bae2f0 100644 --- a/src/main/resources/static/sdk/test.html +++ b/src/main/resources/static/sdk/test.html @@ -11,8 +11,8 @@ - +