8 Commits

Author SHA1 Message Date
wanghanlin 3038cd0387 fix(security): CORS 拆分为 SDK 开放与管理白名单双轨 2 months ago
wanghanlin 9fe3ee6828 fix(sdk): 修复历史会话不显示图片附件 2 months ago
wanghanlin 081758128c fix(app): 修复切换会话后用户提问被查询重写结果覆盖 2 months ago
wanghanlin 377aad48db fix(app): 修复切换推理模型后对话流式超时 2 months ago
wanghanlin 0aa11a5ec6 fix(app): 修复推荐问题显示哨兵标记异常 2 months ago
wanghanlin d5d392aca4 feat(sdk): 支持图片上传与多模态对话 2 months ago
wanghanlin 5856237746 fix(frontend): 移除分类管理列表中的文档数量显示 2 months ago
wanghanlin 9338fcbd5f feat(security): JWT token_version 服务端撤销与登录安全加固 2 months ago
  1. 15
      CLAUDE.md
  2. 70
      client/src/api.ts
  3. 132
      client/src/chat.ts
  4. 1
      client/src/config.ts
  5. 28
      client/src/dom.ts
  6. 2
      client/src/i18n.ts
  7. 1
      client/src/index.ts
  8. 20
      client/src/types.ts
  9. 30
      frontend/src/App.vue
  10. 16
      frontend/src/api/auth.ts
  11. 64
      frontend/src/api/request.ts
  12. 4
      frontend/src/layouts/MainLayout.vue
  13. 91
      frontend/src/layouts/Topbar.vue
  14. 18
      frontend/src/stores/auth.ts
  15. 6
      frontend/src/types/models.ts
  16. 4
      frontend/src/utils/sse.ts
  17. 21
      frontend/src/utils/token.ts
  18. 2
      frontend/src/views/CategoryManager.vue
  19. 5
      frontend/src/views/LoginPage.vue
  20. 13
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  21. 6
      src/main/java/com/wok/supportbot/app/ChatPipeline.java
  22. 2
      src/main/java/com/wok/supportbot/app/ChatRequest.java
  23. 9
      src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java
  24. 189
      src/main/java/com/wok/supportbot/auth/AuthController.java
  25. 4
      src/main/java/com/wok/supportbot/config/ChatModelFactory.java
  26. 31
      src/main/java/com/wok/supportbot/config/CorsConfig.java
  27. 65
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  28. 6
      src/main/java/com/wok/supportbot/controller/AttachmentController.java
  29. 32
      src/main/java/com/wok/supportbot/controller/SysUserController.java
  30. 70
      src/main/java/com/wok/supportbot/converter/MessageConverter.java
  31. 4
      src/main/java/com/wok/supportbot/entity/SysUser.java
  32. 5
      src/main/java/com/wok/supportbot/handler/MyMetaObjectHandler.java
  33. 4
      src/main/java/com/wok/supportbot/rag/RagContext.java
  34. 29
      src/main/java/com/wok/supportbot/security/JwtAuthFilter.java
  35. 69
      src/main/java/com/wok/supportbot/security/JwtTokenProvider.java
  36. 95
      src/main/java/com/wok/supportbot/security/LoginAttemptService.java
  37. 48
      src/main/java/com/wok/supportbot/security/PasswordValidator.java
  38. 43
      src/main/java/com/wok/supportbot/security/SdkAuthFilter.java
  39. 18
      src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java
  40. 47
      src/main/java/com/wok/supportbot/security/SecurityConfig.java
  41. 9
      src/main/java/com/wok/supportbot/service/ConversationService.java
  42. 63
      src/main/java/com/wok/supportbot/service/SysUserService.java
  43. 15
      src/main/resources/application-dev.yml
  44. 16
      src/main/resources/application-prod.yml
  45. 28
      src/main/resources/application.yml
  46. 222
      src/main/resources/init-database.sql
  47. 6
      src/main/resources/static/sdk/test.html
  48. 16
      src/main/resources/support-bot.sql

15
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) 同源静态资源 `<script crossorigin>`/`<link crossorigin>` 带 `Origin` 头、白名单缺线上域名 → 首页空白 + assets 403;(2) SDK 第三方跨域、第三方域名不在白名单 → 接口 403。教训:改 CORS 必须同时考虑「同源静态资源的 crossorigin 属性」和「SDK 第三方跨域」两个场景。
**部署提醒**: 更换前端域名/端口时,必须同步把新域名加入 `application-prod.yml` 的 `app.cors.allowed-origins`,否则同源静态资源会 403。
## API 路由约定
- AI 对话: `/ai/*`(`AiController`)

70
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<string, string>);
}
}
// 为受 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<string> {
const url = buildChatUrl(message);
export async function chatRequest(message: string, imageUrls?: string[]): Promise<string> {
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<void> {
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<ImageAttachment> {
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: 知识库分类 ====================
/**
@ -599,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();

132
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[] = [];
/**
* 初始化对话模块
*/
@ -177,6 +181,12 @@ async function loadHistoryFromBackend(): Promise<void> {
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(),
}));
@ -195,12 +205,20 @@ async function loadHistoryFromBackend(): Promise<void> {
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 +227,61 @@ function bindSendEvents(): void {
}
});
// t-chat-sender:fileSelect 事件(选择文件后触发,携带原始 File[])
inputEl.addEventListener('fileSelect', (e) => {
const files = (e as CustomEvent<File[]>).detail;
if (!files || files.length === 0) return;
handleFileSelect(files);
});
if (clearBtn) {
clearBtn.addEventListener('click', () => handleClear());
}
}
/**
* 处理文件选择:逐个上传到 /attachment/upload,成功后同步到输入区附件预览
*/
async function handleFileSelect(files: File[]): Promise<void> {
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 +472,20 @@ function updateEmptyState(): void {
}
/** 处理发送消息(text 来自 t-chat-sender 的 send 事件,或快捷问题/重试的显式调用) */
async function handleSend(text?: string): Promise<void> {
async function handleSend(text?: string, images?: ImageAttachment[]): Promise<void> {
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 +493,21 @@ async function handleSend(text?: string): Promise<void> {
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<void> {
if (!config || !messagesContainer) return;
async function produceAIReply(userText: string, images?: ImageAttachment[]): Promise<boolean> {
if (!config || !messagesContainer) return false;
isSending = true;
setSendButtonMode('stop');
@ -439,6 +517,9 @@ async function produceAIReply(userText: string): Promise<void> {
await initChatId();
}
// 提取图片 URL,仅图片参与多模态对话
const imageUrls = (images || []).map(img => img.url);
const aiTimestamp = now();
// RAG 启用条件:由 enableRag 控制
const shouldUseRag = useRag;
@ -452,9 +533,9 @@ async function produceAIReply(userText: string): Promise<void> {
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 +565,7 @@ async function produceAIReply(userText: string): Promise<void> {
// 发送成功后清除离线横幅(网络已恢复)
hideOfflineBanner();
return true;
} catch (err) {
if (hideLoadingFn) hideLoadingFn();
@ -492,6 +574,7 @@ async function produceAIReply(userText: string): Promise<void> {
renderErrorBubble(messagesContainer, errMsg, now());
}
logger.error(`发送失败 integrateId=${config.integrateId}`, err);
return false;
} finally {
isSending = false;
abortController = null;
@ -500,7 +583,7 @@ async function produceAIReply(userText: string): Promise<void> {
}
/** 流式发送消息 */
async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag: boolean, aiMsgId: string): Promise<string> {
async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag: boolean, aiMsgId: string, imageUrls?: string[]): Promise<string> {
// 创建中断控制器,供"停止生成"使用
abortController = new AbortController();
const signal = abortController.signal;
@ -535,7 +618,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 +646,7 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag
},
currentCategoryId,
shouldUseRag,
imageUrls,
signal
);
});
@ -597,7 +681,8 @@ export async function retryFromMessage(msgId: string): Promise<void> {
}
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 +708,7 @@ export async function retryFromMessage(msgId: string): Promise<void> {
renderHistory();
// 4. 重新发送该轮提问(重新渲染用户气泡 + 生成新回复)
await handleSend(userText);
await handleSend(userText, userImages);
} finally {
isRetrying = false;
}
@ -694,7 +779,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 +816,7 @@ function handleClear(): void {
if (clearBtn) clearBtn.style.display = 'none';
updateEmptyState();
clearMessages(config.integrateId);
clearPendingImages();
// 生成新的 chatId,开始新会话
const newId = generateNewChatId();
@ -789,6 +875,7 @@ export async function switchRole(newRoleId: string): Promise<void> {
messages = [];
const msgNodes = messagesContainer.querySelectorAll('.csk-msg, .csk-loading');
msgNodes.forEach(el => el.remove());
clearPendingImages();
// 5. 清空新旧角色的 localStorage 缓存(消息 + chatId),必须在 setActiveRoleId 之前
clearMessages(oldRoleId);
@ -928,6 +1015,7 @@ async function switchToConversation(conversationId: string): Promise<void> {
messages = [];
const msgs = messagesContainer.querySelectorAll('.csk-msg, .csk-loading');
msgs.forEach(el => el.remove());
clearPendingImages();
// 4. 从后端加载该会话的消息
try {
@ -937,6 +1025,12 @@ async function switchToConversation(conversationId: string): Promise<void> {
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(),
}));

1
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,

28
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);

2
client/src/i18n.ts

@ -74,6 +74,7 @@ const dictionaries: Record<string, Record<string, string>> = {
error_unavailable: '服务暂不可用,请稍后重试',
error_unknown: '请求发生未知错误',
error_send: '发送失败,请稍后重试',
error_upload_failed: '图片上传失败,请稍后重试',
error_stream_unsupported: '浏览器不支持流式读取',
},
@ -147,6 +148,7 @@ const dictionaries: Record<string, Record<string, string>> = {
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',
},
};

1
client/src/index.ts

@ -28,6 +28,7 @@ export type {
SDKConfig,
ResolvedConfig,
ChatMessage,
ImageAttachment,
RagSource,
CategoryNode,
ChatbotSDKInstance,

20
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',预留后端对接位 */

30
frontend/src/App.vue

@ -6,11 +6,11 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, onUnmounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import LoginPage from '@/views/LoginPage.vue'
import MainLayout from '@/layouts/MainLayout.vue'
import { isLoggedIn, clearTokens } from '@/utils/token'
import { isLoggedIn, cleanupLegacyTokens } from '@/utils/token'
import { toast } from '@/utils/toast'
const auth = useAuthStore()
@ -19,7 +19,16 @@ function onLoginSuccess(user: any) {
auth.onLoginSuccess(user)
}
// 401 全局事件处理器:拦截器已尝试刷新并失败后触发,这里直接登出
async function handleUnauthorized() {
await auth.doLogout()
toast('登录已过期,请重新登录', 'error')
}
onMounted(async () => {
// 启动时清理历史遗留的 refresh token
cleanupLegacyTokens()
// 启动时验证登录态
if (isLoggedIn()) {
const ok = await auth.fetchUser()
@ -29,20 +38,15 @@ onMounted(async () => {
if (refreshed) {
await auth.fetchUser().catch(() => {})
} else {
auth.logout()
await auth.doLogout()
}
}
}
// 监听 401 事件
window.addEventListener('auth:unauthorized', async () => {
const refreshed = await auth.tryRefreshToken()
if (refreshed) {
await auth.fetchUser().catch(() => {})
} else {
auth.logout()
toast('登录已过期,请重新登录', 'error')
}
})
window.addEventListener('auth:unauthorized', handleUnauthorized)
})
onUnmounted(() => {
window.removeEventListener('auth:unauthorized', handleUnauthorized)
})
</script>

16
frontend/src/api/auth.ts

@ -6,9 +6,19 @@ export function login(username: string, password: string): Promise<ApiResponse<T
return request.post('/auth/login', { username, password }).then(r => r.data)
}
/** 刷新 Token */
export function refreshToken(refreshToken: string): Promise<ApiResponse<TokenResponse>> {
return request.post('/auth/refresh', { refreshToken }).then(r => r.data)
/** 刷新 Token(refresh token 通过 httpOnly Cookie 自动携带) */
export function refreshToken(): Promise<ApiResponse<TokenResponse>> {
return request.post('/auth/refresh').then(r => r.data)
}
/** 退出登录(清除服务端 refresh token cookie) */
export function logout(): Promise<ApiResponse<void>> {
return request.post('/auth/logout').then(r => r.data)
}
/** 当前登录用户自助修改密码 */
export function changePassword(oldPassword: string, newPassword: string): Promise<ApiResponse<void>> {
return request.put('/auth/password', { oldPassword, newPassword }).then(r => r.data)
}
/** 获取当前登录用户信息 */

64
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')
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))
}
)

4
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')
}

91
frontend/src/layouts/Topbar.vue

@ -13,21 +13,108 @@
<span class="user-name">{{ displayName }}</span>
<t-tag v-for="r in roles" :key="r" size="small" variant="light" theme="primary">{{ r }}</t-tag>
</span>
<t-button size="small" variant="outline" theme="default" @click="openPasswordDialog">改密</t-button>
<t-button size="small" variant="outline" theme="default" @click="$emit('logout')">退出</t-button>
</div>
<t-dialog
v-model:visible="passwordVisible"
header="修改密码"
width="400px"
:confirm-btn="{ content: '确认修改', loading: passwordLoading }"
:on-confirm="submitPassword"
@close="resetPasswordForm"
>
<t-form :data="passwordForm" :rules="passwordRules" label-width="80px">
<t-form-item label="旧密码" name="oldPassword">
<t-input v-model="passwordForm.oldPassword" type="password" placeholder="请输入旧密码" />
</t-form-item>
<t-form-item label="新密码" name="newPassword">
<t-input v-model="passwordForm.newPassword" type="password" placeholder="8-64 位,含字母和数字" />
</t-form-item>
<t-form-item label="确认密码" name="confirmPassword">
<t-input v-model="passwordForm.confirmPassword" type="password" placeholder="请再次输入新密码" />
</t-form-item>
</t-form>
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, reactive, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { changePassword } from '@/api/auth'
import { toast } from '@/utils/toast'
defineEmits<{ toggleMobileMenu: []; logout: [] }>()
const emit = defineEmits<{ toggleMobileMenu: []; logout: [] }>()
const auth = useAuthStore()
const displayName = computed(() => auth.currentUser.nickname || auth.currentUser.username || '')
const avatarChar = computed(() => (auth.currentUser.nickname || auth.currentUser.username || 'U').charAt(0))
const roles = computed(() => auth.currentUser.roles || [])
// 修改密码弹窗
const passwordVisible = ref(false)
const passwordLoading = ref(false)
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: '',
})
const passwordRules = {
oldPassword: [{ required: true, message: '请输入旧密码' }],
newPassword: [{ required: true, message: '请输入新密码' }],
confirmPassword: [{ required: true, message: '请确认新密码' }],
}
function openPasswordDialog() {
resetPasswordForm()
passwordVisible.value = true
}
function resetPasswordForm() {
passwordForm.oldPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
}
async function submitPassword() {
if (!passwordForm.oldPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
toast('请填写完整密码信息', 'error')
return
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast('两次输入的新密码不一致', 'error')
return
}
if (passwordForm.newPassword.length < 8 || passwordForm.newPassword.length > 64) {
toast('新密码长度需在 8-64 位之间', 'error')
return
}
const hasLetter = /[a-zA-Z]/.test(passwordForm.newPassword)
const hasDigit = /\d/.test(passwordForm.newPassword)
if (!hasLetter || !hasDigit) {
toast('新密码需同时包含字母和数字', 'error')
return
}
passwordLoading.value = true
try {
const res = await changePassword(passwordForm.oldPassword, passwordForm.newPassword)
if (res.success) {
toast(res.message || '密码修改成功,请重新登录', 'success')
passwordVisible.value = false
// 改密后 token 已失效,调用退出登录流程
emit('logout')
} else {
toast(res.message || '修改失败', 'error')
}
} catch (e: any) {
toast(e.message || '修改失败', 'error')
}
passwordLoading.value = false
}
</script>
<style scoped>

18
frontend/src/stores/auth.ts

@ -3,8 +3,8 @@
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getToken, isLoggedIn, clearTokens, getUserInfo, setUserInfo, setToken, setRefreshToken } from '@/utils/token'
import { getCurrentUser, refreshToken } from '@/api/auth'
import { getToken, isLoggedIn, clearTokens, getUserInfo, setUserInfo, setToken, cleanupLegacyTokens } from '@/utils/token'
import { getCurrentUser, refreshToken, logout } from '@/api/auth'
export const useAuthStore = defineStore('auth', () => {
const currentUser = ref<Record<string, any>>(getUserInfo())
@ -30,20 +30,20 @@ export const useAuthStore = defineStore('auth', () => {
}
async function tryRefreshToken(): Promise<boolean> {
const rt = localStorage.getItem('sb_refresh_token')
if (!rt) return false
try {
const res = await refreshToken(rt)
const res = await refreshToken()
if (res.success && res.data) {
setToken(res.data.accessToken)
setRefreshToken(res.data.refreshToken)
return true
}
} catch { /* ignore */ }
return false
}
function logout(): void {
async function doLogout(): Promise<void> {
try {
await logout()
} catch { /* ignore */ }
clearTokens()
loggedIn.value = false
currentUser.value = {}
@ -53,7 +53,9 @@ export const useAuthStore = defineStore('auth', () => {
currentUser.value = user || getUserInfo()
setUserInfo(user || {})
loggedIn.value = true
// 清理历史遗留的 localStorage refresh token
cleanupLegacyTokens()
}
return { currentUser, loggedIn, isAdmin, hasRole, fetchUser, tryRefreshToken, logout, onLoginSuccess }
return { currentUser, loggedIn, isAdmin, hasRole, fetchUser, tryRefreshToken, doLogout, onLoginSuccess }
})

6
frontend/src/types/models.ts

@ -15,10 +15,12 @@ export interface LoginRequest {
password: string
}
/** Token 响应 */
/** Token 响应
* refreshToken 已改为 httpOnly Cookie 返回,接口 body 中不再包含。
*/
export interface TokenResponse {
accessToken: string
refreshToken: string
refreshToken?: string
}
// ==================== 知识库文档 ====================

4
frontend/src/utils/sse.ts

@ -49,7 +49,7 @@ export async function readSSEStream(
headers?: Record<string, string>,
signal?: AbortSignal
): Promise<void> {
const res = await fetch(url, { headers: authHeaders(headers), signal })
const res = await fetch(url, { headers: authHeaders(headers), signal, credentials: 'include' })
if (!res.ok) throw new Error('HTTP ' + res.status)
const reader = res.body!.getReader()
const decoder = new TextDecoder()
@ -130,7 +130,7 @@ export async function readSSEStreamWithEvents(
signal?: AbortSignal
): Promise<void> {
const { onMessage, onToolCallStart, onToolCallResult, onError, onDone } = handlers
const res = await fetch(url, { headers: authHeaders(headers), signal })
const res = await fetch(url, { headers: authHeaders(headers), signal, credentials: 'include' })
if (!res.ok) throw new Error('HTTP ' + res.status)
const reader = res.body!.getReader()
const decoder = new TextDecoder()

21
frontend/src/utils/token.ts

@ -1,10 +1,9 @@
/**
* Token 管理工具
* 基于 localStorage 存取 JWT Token 和用户信息
* Access Token 管理工具
* 基于 localStorage 存取 access token 和用户信息;refresh token 已改为 httpOnly Cookie,不在这里管理。
*/
const TOKEN_KEY = 'sb_token'
const REFRESH_TOKEN_KEY = 'sb_refresh_token'
const USER_KEY = 'sb_user'
export function getToken(): string {
@ -15,17 +14,8 @@ export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
export function getRefreshToken(): string {
return localStorage.getItem(REFRESH_TOKEN_KEY) || ''
}
export function setRefreshToken(token: string): void {
localStorage.setItem(REFRESH_TOKEN_KEY, token)
}
export function clearTokens(): void {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
@ -44,3 +34,10 @@ export function setUserInfo(info: Record<string, any>): void {
export function isLoggedIn(): boolean {
return !!getToken()
}
/**
* 清除已废弃的 refresh token 本地存储(历史兼容)
*/
export function cleanupLegacyTokens(): void {
localStorage.removeItem('sb_refresh_token')
}

2
frontend/src/views/CategoryManager.vue

@ -26,7 +26,6 @@
<span v-else style="width:16px;" />
<strong class="tree-name">{{ node.name }}</strong>
<span v-if="node.description" class="tree-desc">{{ node.description }}</span>
<span class="tree-doc-count">({{ node.documentCount||0 }} 篇)</span>
</div>
<t-space :size="4">
<t-button size="small" variant="text" @click="openEdit(node)">编辑</t-button>
@ -120,5 +119,4 @@ function countDescendants(node:any):number{if(!node.children?.length)return 0;le
.tree-arrow{cursor:pointer;user-select:none;font-size:12px;width:16px;text-align:center;color:var(--color-text-tertiary);}
.tree-name{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.tree-desc{color:var(--color-text-tertiary);font-size:12px;margin-left:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.tree-doc-count{color:var(--color-text-tertiary);font-size:11px;margin-left:8px;white-space:nowrap;}
</style>

5
frontend/src/views/LoginPage.vue

@ -23,7 +23,7 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { login } from '@/api/auth'
import { setToken, setRefreshToken, setUserInfo } from '@/utils/token'
import { setToken, setUserInfo, cleanupLegacyTokens } from '@/utils/token'
import { getCurrentUser } from '@/api/auth'
const emit = defineEmits<{ loginSuccess: [user: any] }>()
@ -43,7 +43,7 @@ async function handleLogin() {
const res = await login(form.username, form.password)
if (res.success && res.data) {
setToken(res.data.accessToken)
setRefreshToken(res.data.refreshToken)
cleanupLegacyTokens()
// 获取用户信息
const userRes = await getCurrentUser()
if (userRes.success && userRes.data) {
@ -56,6 +56,7 @@ async function handleLogin() {
errorMsg.value = res.message || '登录失败'
}
} catch (e: any) {
// 拦截器已将后端错误包装为 Error,直接读取 message
errorMsg.value = e.message || '登录失败'
}
loading.value = false

13
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));
}
/**

6
src/main/java/com/wok/supportbot/app/ChatPipeline.java

@ -66,7 +66,7 @@ public class ChatPipeline {
* <li>未启用 RAG(普通对话 / 严格隔离下 KB 拒绝)→ 用原始 message、基础 system</li>
* <li>寒暄/闲聊(IntentRouter 或寒暄词命中)→ 同上,跳过 KB 检索</li>
* <li>FAQ 命中 → 直接返回标准答案,不调用 ChatClient</li>
* <li>RAG 生成 → 资料块注入 system,重写后查询作为 user 消息</li>
* <li>RAG 生成 → 资料块注入 system,原始 message 作为 user 消息(重写查询仅用于检索)</li>
* </ul>
*
* @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);
}

2
src/main/java/com/wok/supportbot/app/ChatRequest.java

@ -15,7 +15,7 @@ import java.util.Optional;
* 字段含义:
* <ul>
* <li>{@link #faqAnswer()}:FAQ 命中时直接返回标准答案,跳过 ChatClient 调用</li>
* <li>{@link #finalMessage()}:传给模型的用户消息(RAG 场景为重写后的查询,否则为原始 message)</li>
* <li>{@link #finalMessage()}:传给模型的用户消息(始终为原始 message,查询重写仅用于检索)</li>
* <li>{@link #finalSystemPrompt()}:传给模型的系统提示词(角色人设 + RAG 资料块),可为空</li>
* <li>{@link #globalPrompt()}:全局提示词快照(来自 system_config.ai_system_prompt),可为空</li>
* <li>{@link #ragContextText()}:RAG 资料块文本(未注入时为空/null)</li>

9
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);

189
src/main/java/com/wok/supportbot/auth/AuthController.java

@ -3,14 +3,22 @@ package com.wok.supportbot.auth;
import com.wok.supportbot.entity.SysRole;
import com.wok.supportbot.entity.SysUser;
import com.wok.supportbot.security.JwtTokenProvider;
import com.wok.supportbot.security.LoginAttemptService;
import com.wok.supportbot.security.PasswordValidator;
import com.wok.supportbot.service.SysUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
@ -19,7 +27,7 @@ import java.util.Map;
/**
* 认证控制器
* 处理登录、Token 刷新、当前用户信息查询
* 处理登录、Token 刷新、当前用户信息查询、自助改密
*/
@Slf4j
@RestController
@ -29,28 +37,51 @@ public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
private final SysUserService sysUserService;
private final PasswordEncoder passwordEncoder;
private final LoginAttemptService loginAttemptService;
private final PasswordValidator passwordValidator;
private final boolean refreshCookieSecure;
public AuthController(AuthenticationManager authenticationManager,
JwtTokenProvider jwtTokenProvider,
SysUserService sysUserService) {
SysUserService sysUserService,
PasswordEncoder passwordEncoder,
LoginAttemptService loginAttemptService,
PasswordValidator passwordValidator,
@Value("${jwt.refresh-cookie-secure:false}") boolean refreshCookieSecure) {
this.authenticationManager = authenticationManager;
this.jwtTokenProvider = jwtTokenProvider;
this.sysUserService = sysUserService;
this.passwordEncoder = passwordEncoder;
this.loginAttemptService = loginAttemptService;
this.passwordValidator = passwordValidator;
this.refreshCookieSecure = refreshCookieSecure;
}
/**
* 用户登录
* 登录成功后通过 httpOnly Cookie 返回 refresh token,body 中只返回 access token。
*/
@PostMapping("/login")
public ResponseEntity<Map<String, Object>> login(@RequestBody Map<String, String> body) {
String username = body.get("username");
String password = body.get("password");
if (username == null || password == null) {
if (username == null || password == null || username.isBlank() || password.isBlank()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false, "message", "用户名和密码不能为空"
));
}
// 检查账号是否因暴力破解被锁定
int lockedSeconds = loginAttemptService.getLockedSeconds(username);
if (lockedSeconds > 0) {
int minutes = (lockedSeconds + 59) / 60;
return ResponseEntity.status(423).body(Map.of(
"success", false,
"message", "账号已锁定,请 " + minutes + " 分钟后重试"
));
}
try {
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, password)
@ -62,33 +93,37 @@ public class AuthController {
List<SysRole> roles = user.getRoles();
List<String> roleKeys = roles.stream().map(SysRole::getRoleKey).toList();
// 生成 Token
String accessToken = jwtTokenProvider.generateToken(username, roleKeys);
String refreshToken = jwtTokenProvider.generateRefreshToken(username);
int tokenVersion = user.getTokenVersion() == null ? 0 : user.getTokenVersion();
String accessToken = jwtTokenProvider.generateToken(username, roleKeys, tokenVersion);
String refreshToken = jwtTokenProvider.generateRefreshToken(username, tokenVersion);
// 更新最后登录时间
// 更新最后登录时间并清除失败记录
sysUserService.updateLastLoginTime(user.getId());
loginAttemptService.recordSuccess(username);
// 构建用户信息
Map<String, Object> userInfo = new LinkedHashMap<>();
userInfo.put("id", user.getId().toString());
userInfo.put("username", user.getUsername());
userInfo.put("nickname", user.getNickname());
userInfo.put("email", user.getEmail());
userInfo.put("roles", roleKeys);
userInfo.put("avatar", user.getAvatar());
Map<String, Object> userInfo = buildUserInfo(user, roleKeys);
ResponseCookie refreshCookie = buildRefreshCookie(refreshToken, 7 * 24 * 60 * 60L);
log.info("用户登录成功: {}", username);
return ResponseEntity.ok(Map.of(
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
.body(Map.of(
"success", true,
"data", Map.of(
"accessToken", accessToken,
"refreshToken", refreshToken,
"user", userInfo
)
));
} catch (BadCredentialsException e) {
return ResponseEntity.ok(Map.of(
// 密码错误等认证失败场景,记录失败次数
loginAttemptService.recordFailure(username);
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "用户名或密码错误"
));
} catch (AuthenticationException e) {
// 用户不存在、账号禁用等也统一返回 401,避免泄露账号状态
loginAttemptService.recordFailure(username);
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "用户名或密码错误"
));
}
@ -96,34 +131,114 @@ public class AuthController {
/**
* 刷新 Token
* 从 httpOnly Cookie 读取 refresh token,换发新的 access token 和 refresh cookie。
*/
@PostMapping("/refresh")
public ResponseEntity<Map<String, Object>> refresh(@RequestBody Map<String, String> body) {
String refreshToken = body.get("refreshToken");
if (refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) {
return ResponseEntity.ok(Map.of(
public ResponseEntity<Map<String, Object>> refresh(
@CookieValue(name = "refresh_token", required = false) String refreshToken) {
if (refreshToken == null || !jwtTokenProvider.validateRefreshToken(refreshToken)) {
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "刷新令牌无效或已过期"
));
}
String username = jwtTokenProvider.getUsernameFromToken(refreshToken);
int tokenVer = jwtTokenProvider.getTokenVersionFromToken(refreshToken);
SysUser user = sysUserService.getUserByUsername(username);
if (user == null || Boolean.FALSE.equals(user.getEnabled())) {
return ResponseEntity.ok(Map.of(
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "用户不存在或已被禁用"
));
}
int currentVer = user.getTokenVersion() == null ? 0 : user.getTokenVersion();
if (currentVer != tokenVer) {
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "登录状态已失效,请重新登录"
));
}
List<String> roleKeys = user.getRoles().stream().map(SysRole::getRoleKey).toList();
String newAccessToken = jwtTokenProvider.generateToken(username, roleKeys);
String newRefreshToken = jwtTokenProvider.generateRefreshToken(username);
String newAccessToken = jwtTokenProvider.generateToken(username, roleKeys, currentVer);
String newRefreshToken = jwtTokenProvider.generateRefreshToken(username, currentVer);
return ResponseEntity.ok(Map.of(
ResponseCookie refreshCookie = buildRefreshCookie(newRefreshToken, 7 * 24 * 60 * 60L);
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
.body(Map.of(
"success", true,
"data", Map.of(
"accessToken", newAccessToken,
"refreshToken", newRefreshToken
)
"data", Map.of("accessToken", newAccessToken)
));
}
/**
* 退出登录:清除 refresh token cookie,并使当前 access token 立即失效
*/
@PostMapping("/logout")
public ResponseEntity<Map<String, Object>> logout() {
// 递增 token_version,使已签发的 access token 立即失效(剩余有效期内的令牌不再可用)
// 注意:改密/禁用后 token 已失效,SecurityContext 可能为空,此时仅清除 cookie 即可
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
SysUser user = sysUserService.getUserByUsername(auth.getName());
if (user != null) {
sysUserService.incrementTokenVersion(user.getId());
}
}
ResponseCookie clearCookie = ResponseCookie.from("refresh_token", "")
.httpOnly(true)
.secure(refreshCookieSecure)
.sameSite("Strict")
.path("/auth/refresh")
.maxAge(0)
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, clearCookie.toString())
.body(Map.of("success", true, "message", "已退出登录"));
}
/**
* 当前登录用户自助修改密码(必须校验旧密码)
*/
@PutMapping("/password")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Map<String, Object>> changePassword(@RequestBody Map<String, String> body) {
String oldPassword = body.get("oldPassword");
String newPassword = body.get("newPassword");
if (oldPassword == null || oldPassword.isBlank() || newPassword == null || newPassword.isBlank()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false, "message", "旧密码和新密码不能为空"
));
}
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
SysUser user = sysUserService.getUserByUsername(username);
if (user == null) {
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "用户不存在"
));
}
if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "旧密码错误"
));
}
try {
passwordValidator.validate(newPassword);
sysUserService.changePassword(user.getId(), newPassword);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of(
"success", false, "message", e.getMessage()
));
}
return ResponseEntity.ok(Map.of(
"success", true, "message", "密码已修改,请重新登录"
));
}
@ -148,7 +263,10 @@ public class AuthController {
}
List<String> roleKeys = user.getRoles().stream().map(SysRole::getRoleKey).toList();
return ResponseEntity.ok(Map.of("success", true, "data", buildUserInfo(user, roleKeys)));
}
private Map<String, Object> buildUserInfo(SysUser user, List<String> roleKeys) {
Map<String, Object> userInfo = new LinkedHashMap<>();
userInfo.put("id", user.getId().toString());
userInfo.put("username", user.getUsername());
@ -156,7 +274,16 @@ public class AuthController {
userInfo.put("email", user.getEmail());
userInfo.put("roles", roleKeys);
userInfo.put("avatar", user.getAvatar());
return userInfo;
}
return ResponseEntity.ok(Map.of("success", true, "data", userInfo));
private ResponseCookie buildRefreshCookie(String refreshToken, long maxAgeSeconds) {
return ResponseCookie.from("refresh_token", refreshToken)
.httpOnly(true)
.secure(refreshCookieSecure)
.sameSite("Strict")
.path("/auth/refresh")
.maxAge(maxAgeSeconds)
.build();
}
}

4
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 兼容路径共用。

31
src/main/java/com/wok/supportbot/config/CorsConfig.java

@ -1,25 +1,48 @@
package com.wok.supportbot.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays;
import java.util.List;
/**
* 全局跨域配置
* 与 SecurityConfig 中的 CORS 配置保持一致的来源白名单。
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
private final List<String> allowedOrigins;
public CorsConfig(
@Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:5174,http://localhost:9090}") String allowedOrigins) {
this.allowedOrigins = Arrays.stream(allowedOrigins.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// 覆盖所有请求
registry.addMapping("/**")
// 允许发送 Cookie
// SDK 第三方接入接口:开放跨域(第三方域名动态未知,使用 Bearer Token 鉴权,无 refresh cookie 暴露风险)
for (String path : new String[]{"/ai/**", "/category/tree", "/category/list", "/feedback", "/attachment/upload"}) {
registry.addMapping(path)
.allowCredentials(true)
// 放行哪些域名(必须用 patterns,否则 * 会和 allowCredentials 冲突)
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("*");
}
// 管理后台接口:来源白名单(保护 httpOnly refresh cookie,防跨域 CSRF)
registry.addMapping("/**")
.allowCredentials(true)
.allowedOriginPatterns(allowedOrigins.toArray(new String[0]))
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("*");
}
}

65
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -155,6 +155,7 @@ public class DatabaseInitConfig {
createSysUserTable();
}
});
safeInit("迁移 sys_user.token_version 列", this::addTokenVersionColumn);
safeInit("创建系统角色表 sys_role", () -> {
if (!checkTableExists("sys_role")) {
createSysRoleTable();
@ -234,6 +235,13 @@ public class DatabaseInitConfig {
});
safeInit("同步默认系统配置", this::syncDefaultSystemConfigs);
// ==================== 登录安全 ====================
safeInit("创建登录失败记录表 login_attempt", () -> {
if (!checkTableExists("login_attempt")) {
createLoginAttemptTable();
}
});
// 为所有表添加注释(幂等,可重复执行)
safeInit("应用数据库表注释", this::applyTableComments);
@ -269,7 +277,8 @@ public class DatabaseInitConfig {
"api_key", "webhook_config",
"mcp_server_config",
"system_config",
"llm_call_trace"
"llm_call_trace",
"login_attempt"
};
java.util.List<String> missingTables = new java.util.ArrayList<>();
@ -953,6 +962,7 @@ public class DatabaseInitConfig {
phone VARCHAR(20),
avatar VARCHAR(512),
enabled BOOLEAN DEFAULT TRUE NOT NULL,
token_version INT DEFAULT 0 NOT NULL,
last_login_time TIMESTAMP,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
@ -1009,6 +1019,45 @@ public class DatabaseInitConfig {
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_sys_user_role_role ON sys_user_role (role_id)");
}
/**
* 迁移 sys_user.token_version 列(JWT 服务端撤销机制,幂等)
*/
private void addTokenVersionColumn() {
try {
String checkSql = """
SELECT COUNT(*) FROM information_schema.columns
WHERE table_name = 'sys_user' AND column_name = 'token_version'
""";
Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class);
if (count != null && count == 0) {
jdbcTemplate.execute("ALTER TABLE sys_user ADD COLUMN token_version INT DEFAULT 0 NOT NULL");
log.info("已迁移 sys_user.token_version 列");
}
} catch (Exception e) {
log.error("迁移 sys_user.token_version 列失败: {}", e.getMessage());
throw e;
}
}
/**
* 创建登录失败记录表(暴力破解防护)
*/
private void createLoginAttemptTable() {
String sql = """
CREATE TABLE IF NOT EXISTS login_attempt (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
failed_count INT DEFAULT 0 NOT NULL,
last_failed_time TIMESTAMP,
locked_until TIMESTAMP,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""";
jdbcTemplate.execute(sql);
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_login_attempt_locked ON login_attempt (locked_until)");
}
/**
* chat_message 表增加 user_id 列(数据隔离迁移,幂等)
*/
@ -1335,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("""

6
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 {
/**
* 上传图片/附件
*
* <p>鉴权由 {@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<Map<String, Object>> upload(@RequestParam("file") MultipartFile file) {
try {
validateFile(file);

32
src/main/java/com/wok/supportbot/controller/SysUserController.java

@ -76,10 +76,7 @@ public class SysUserController {
String nickname = (String) body.get("nickname");
String email = (String) body.get("email");
String phone = (String) body.get("phone");
@SuppressWarnings("unchecked")
List<Long> roleIds = body.get("roleIds") != null
? ((List<Number>) body.get("roleIds")).stream().map(Number::longValue).toList()
: List.of();
List<Long> roleIds = parseRoleIds(body.get("roleIds"));
var user = sysUserService.createUser(username, password, nickname, email, phone, roleIds);
return ResponseEntity.ok(Map.of("success", true, "message", "创建成功", "data", Map.of("id", user.getId().toString())));
@ -141,10 +138,7 @@ public class SysUserController {
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> assignRoles(@PathVariable Long id, @RequestBody Map<String, Object> body) {
try {
@SuppressWarnings("unchecked")
List<Long> roleIds = body.get("roleIds") != null
? ((List<Number>) body.get("roleIds")).stream().map(Number::longValue).toList()
: List.of();
List<Long> roleIds = parseRoleIds(body.get("roleIds"));
sysUserService.assignRoles(id, roleIds);
return ResponseEntity.ok(Map.of("success", true, "message", "角色分配成功"));
} catch (Exception e) {
@ -194,4 +188,26 @@ public class SysUserController {
));
}
}
/**
* 解析请求体中的 roleIds 字段为 Long 列表。
* 兼容前端传数字 ID 或字符串 ID 两种情况(雪花 ID 超过 JS 精度时前端通常传字符串)。
*/
private List<Long> parseRoleIds(Object roleIdsObj) {
if (roleIdsObj == null) {
return List.of();
}
if (!(roleIdsObj instanceof List<?> list)) {
return List.of();
}
return list.stream()
.filter(java.util.Objects::nonNull)
.map(item -> {
if (item instanceof Number number) {
return number.longValue();
}
return Long.parseLong(String.valueOf(item));
})
.toList();
}
}

70
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<Media> mediaList = userMessage.getMedia();
if (mediaList != null && !mediaList.isEmpty()) {
List<Map<String, String>> mediaJson = new ArrayList<>();
for (Media media : mediaList) {
Map<String, String> 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> media = extractMedia(cleanMetadata);
yield media.isEmpty() ? new UserMessage(text)
: UserMessage.builder().text(text).media(media).build();
}
case ASSISTANT -> {
List<AssistantMessage.ToolCall> 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<Media> extractMedia(Map<String, Object> metadata) {
Object raw = metadata.remove(META_KEY_MEDIA);
if (!(raw instanceof List<?> list) || list.isEmpty()) {
return List.of();
}
List<Media> 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;
}
}
}

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

@ -60,6 +60,10 @@ public class SysUser implements Serializable {
@TableField("enabled")
private Boolean enabled;
/** JWT 令牌版本号,改密/禁用/角色变更时自增,用于服务端撤销已签发 token */
@TableField("token_version")
private Integer tokenVersion;
/** 最后登录时间 */
@TableField("last_login_time")
private Date lastLoginTime;

5
src/main/java/com/wok/supportbot/handler/MyMetaObjectHandler.java

@ -20,6 +20,9 @@ public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date());
// 使用 setFieldValByName 而非 strictUpdateFill:
// strictUpdateFill 仅匹配 FieldFill.UPDATE,而 update_time 为 FieldFill.INSERT_UPDATE,
// 导致 updateById 时 update_time 不会被自动填充(实测 token_version 自增但 update_time 不更新)。
this.setFieldValByName("updateTime", new Date(), metaObject);
}
}

4
src/main/java/com/wok/supportbot/rag/RagContext.java

@ -13,13 +13,13 @@ import java.util.Optional;
* <ul>
* <li>{@link #faqAnswer()} 命中 → 直接返回标准答案,跳过生成</li>
* <li>未命中 → 用 {@link #documents()} 展示引用来源,用 {@link #contextText()} 注入系统提示词,
* 用 {@link #rewrittenQuery()} 作为传给模型的用户消息</li>
* {@link #rewrittenQuery()} 仅用于检索,不作为传给模型的用户消息</li>
* </ul>
*
* @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
*/

29
src/main/java/com/wok/supportbot/security/JwtAuthFilter.java

@ -5,6 +5,7 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
@ -24,9 +25,11 @@ import java.util.List;
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final JdbcTemplate jdbcTemplate;
public JwtAuthFilter(JwtTokenProvider jwtTokenProvider) {
public JwtAuthFilter(JwtTokenProvider jwtTokenProvider, JdbcTemplate jdbcTemplate) {
this.jwtTokenProvider = jwtTokenProvider;
this.jdbcTemplate = jdbcTemplate;
}
@Override
@ -37,9 +40,21 @@ public class JwtAuthFilter extends OncePerRequestFilter {
String token = extractToken(request);
if (StringUtils.hasText(token)) {
boolean valid = jwtTokenProvider.validateToken(token);
boolean valid = jwtTokenProvider.validateAccessToken(token);
if (valid) {
String username = jwtTokenProvider.getUsernameFromToken(token);
int tokenVer = jwtTokenProvider.getTokenVersionFromToken(token);
// 服务端撤销校验:token_version 必须一致且账号处于启用状态
Integer currentVer = jdbcTemplate.query(
"SELECT token_version FROM sys_user WHERE username = ? AND is_delete = false AND enabled = true",
rs -> rs.next() ? rs.getInt("token_version") : null,
username
);
if (currentVer == null || !currentVer.equals(tokenVer)) {
log.warn("JWT Token 版本已失效或账号被禁用: path={}, user={}", path, username);
} else {
List<String> roles = jwtTokenProvider.getRolesFromToken(token);
// 构建 Spring Security 权限列表(ROLE_ 前缀)
@ -51,9 +66,9 @@ public class JwtAuthFilter extends OncePerRequestFilter {
new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("JWT 认证成功: path={}, user={}, roles={}", path, username, roles);
}
} else {
log.warn("JWT Token 验证失败: path={}, tokenPrefix={}",
path, token.length() > 10 ? token.substring(0, 10) + "..." : token);
log.warn("JWT Access Token 验证失败: path={}", path);
}
} else {
log.debug("JWT 未找到 Token: path={}", path);
@ -75,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");
}
/**
@ -84,7 +101,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
private String extractToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
return bearer.substring(7).trim();
}
return null;
}

69
src/main/java/com/wok/supportbot/security/JwtTokenProvider.java

@ -4,6 +4,7 @@ import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@ -16,35 +17,51 @@ import java.util.List;
* JWT 令牌提供者
* 负责 JWT 的生成、验证和解析
*/
@Slf4j
@Component
public class JwtTokenProvider {
/** 默认密钥(禁止使用),必须与 application.yml 中 jwt.secret 的默认值保持一致 */
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 boolean failIfDefaultSecret;
public JwtTokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration:86400000}") long expiration,
@Value("${jwt.refresh-expiration:604800000}") long refreshExpiration) {
// 直接使用 UTF-8 字节作为 HMAC 密钥(密钥长度需 >= 32 字节)
@Value("${jwt.expiration:900000}") long expiration,
@Value("${jwt.refresh-expiration:604800000}") long refreshExpiration,
@Value("${jwt.fail-if-default-secret:false}") boolean failIfDefaultSecret) {
if (DEFAULT_SECRET.equals(secret)) {
String message = "⚠️ 管理后台 JWT 使用了默认密钥,存在安全风险!请在 application.yml 中配置 jwt.secret 为强随机字符串(建议通过环境变量 JWT_SECRET 注入)";
// if (failIfDefaultSecret) {
// throw new IllegalStateException(message);
// }
log.warn(message);
}
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.expiration = expiration;
this.refreshExpiration = refreshExpiration;
this.failIfDefaultSecret = failIfDefaultSecret;
}
/**
* 生成访问令牌
* @param username 用户名
* @param roles 角色列表
* @param tokenVersion 用户当前令牌版本号,用于服务端撤销
* @return JWT 令牌
*/
public String generateToken(String username, List<String> roles) {
public String generateToken(String username, List<String> roles, int tokenVersion) {
Date now = new Date();
return Jwts.builder()
.subject(username)
.claim("roles", roles)
.claim("type", "access")
.claim("ver", tokenVersion)
.issuedAt(now)
.expiration(new Date(now.getTime() + expiration))
.signWith(key)
@ -54,10 +71,12 @@ public class JwtTokenProvider {
/**
* 生成刷新令牌(有效期更长,不含角色信息)
*/
public String generateRefreshToken(String username) {
public String generateRefreshToken(String username, int tokenVersion) {
Date now = new Date();
return Jwts.builder()
.subject(username)
.claim("type", "refresh")
.claim("ver", tokenVersion)
.issuedAt(now)
.expiration(new Date(now.getTime() + refreshExpiration))
.signWith(key)
@ -65,18 +84,19 @@ public class JwtTokenProvider {
}
/**
* 验证令牌有效性
* 验证 access token 有效性(校验签名、过期时间,且 type 必须为 access)
*/
public boolean validateToken(String token) {
try {
parseClaims(token);
return true;
} catch (ExpiredJwtException e) {
// Token 已过期,不打印堆栈
return false;
} catch (Exception e) {
return false;
public boolean validateAccessToken(String token) {
Claims claims = parseClaims(token);
return claims != null && "access".equals(claims.get("type"));
}
/**
* 验证 refresh token 有效性
*/
public boolean validateRefreshToken(String token) {
Claims claims = parseClaims(token);
return claims != null && "refresh".equals(claims.get("type"));
}
/**
@ -99,14 +119,33 @@ public class JwtTokenProvider {
return List.of();
}
/**
* 从令牌中提取 tokenVersion
*/
public int getTokenVersionFromToken(String token) {
Claims claims = parseClaims(token);
Object ver = claims.get("ver");
if (ver instanceof Number number) {
return number.intValue();
}
return 0;
}
/**
* 解析并验证 Claims
*/
private Claims parseClaims(String token) {
try {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
// Token 已过期,不打印堆栈
return null;
} catch (Exception e) {
return null;
}
}
}

95
src/main/java/com/wok/supportbot/security/LoginAttemptService.java

@ -0,0 +1,95 @@
package com.wok.supportbot.security;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
/**
* 登录失败尝试记录服务
* 用于防范暴力破解:同一账号连续失败达到阈值后锁定一段时间。
*/
@Slf4j
@Service
public class LoginAttemptService {
private final JdbcTemplate jdbcTemplate;
private final int maxAttempts;
private final int lockMinutes;
public LoginAttemptService(JdbcTemplate jdbcTemplate,
@Value("${security.login.max-attempts:5}") int maxAttempts,
@Value("${security.login.lock-minutes:15}") int lockMinutes) {
this.jdbcTemplate = jdbcTemplate;
this.maxAttempts = maxAttempts;
this.lockMinutes = lockMinutes;
}
/**
* 返回账号锁定的剩余秒数;未锁定返回 0
*/
public int getLockedSeconds(String username) {
if (username == null || username.isBlank()) {
return 0;
}
Timestamp lockedUntil = jdbcTemplate.query(
"SELECT locked_until FROM login_attempt WHERE username = ?",
rs -> rs.next() ? rs.getTimestamp("locked_until") : null,
username.toLowerCase()
);
if (lockedUntil == null) {
return 0;
}
long remaining = lockedUntil.getTime() - System.currentTimeMillis();
return remaining > 0 ? (int) (remaining / 1000) : 0;
}
/**
* 记录一次登录失败,达到阈值时锁定账号
*/
@Transactional(rollbackFor = Exception.class)
public void recordFailure(String username) {
if (username == null || username.isBlank()) {
return;
}
// 边界保护:login_attempt.username 为 VARCHAR(64),超长用户名截断避免 DataIntegrityViolationException
String name = username.toLowerCase();
if (name.length() > 64) {
name = name.substring(0, 64);
}
jdbcTemplate.update("""
INSERT INTO login_attempt (username, failed_count, last_failed_time, locked_until)
VALUES (?, 1, CURRENT_TIMESTAMP, NULL)
ON CONFLICT (username) DO UPDATE SET
failed_count = CASE
WHEN login_attempt.locked_until IS NOT NULL AND login_attempt.locked_until <= CURRENT_TIMESTAMP THEN 1
ELSE LEAST(login_attempt.failed_count + 1, ?)
END,
last_failed_time = CURRENT_TIMESTAMP,
locked_until = CASE
WHEN login_attempt.locked_until > CURRENT_TIMESTAMP THEN login_attempt.locked_until
WHEN login_attempt.locked_until IS NOT NULL AND login_attempt.locked_until <= CURRENT_TIMESTAMP THEN NULL
WHEN login_attempt.failed_count + 1 >= ? THEN CURRENT_TIMESTAMP + (? * INTERVAL '1 minute')
ELSE NULL
END,
update_time = CURRENT_TIMESTAMP
""", name, Integer.MAX_VALUE, maxAttempts, lockMinutes);
}
/**
* 登录成功后清除失败记录
*/
@Transactional(rollbackFor = Exception.class)
public void recordSuccess(String username) {
if (username == null || username.isBlank()) {
return;
}
int rows = jdbcTemplate.update("DELETE FROM login_attempt WHERE username = ?", username.toLowerCase());
if (rows > 0) {
log.info("登录成功,清除失败记录: {}", username);
}
}
}

48
src/main/java/com/wok/supportbot/security/PasswordValidator.java

@ -0,0 +1,48 @@
package com.wok.supportbot.security;
import org.springframework.stereotype.Component;
/**
* 密码强度校验器
* 统一校验创建用户、修改密码、自助改密等场景的密码复杂度。
*/
@Component
public class PasswordValidator {
private static final int MIN_LENGTH = 8;
private static final int MAX_LENGTH = 64;
/**
* 校验密码强度,不通过则抛出 IllegalArgumentException
*
* 规则:
* - 长度 8-64 位
* - 同时包含字母和数字
*/
public void validate(String password) {
if (password == null || password.length() < MIN_LENGTH || password.length() > MAX_LENGTH) {
throw new IllegalArgumentException("密码长度需在 8-64 位之间");
}
if (!containsLetter(password) || !containsDigit(password)) {
throw new IllegalArgumentException("密码需同时包含字母和数字");
}
}
private boolean containsLetter(String s) {
for (char c : s.toCharArray()) {
if (Character.isLetter(c)) {
return true;
}
}
return false;
}
private boolean containsDigit(String s) {
for (char c : s.toCharArray()) {
if (Character.isDigit(c)) {
return true;
}
}
return false;
}
}

43
src/main/java/com/wok/supportbot/security/SdkAuthFilter.java

@ -10,8 +10,8 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.Ordered;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@ -46,11 +46,17 @@ import java.util.UUID;
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
public class SdkAuthFilter extends OncePerRequestFilter {
@Autowired
private SdkJwtTokenProvider sdkJwtTokenProvider;
private final SdkJwtTokenProvider sdkJwtTokenProvider;
private final JwtTokenProvider jwtTokenProvider;
private final JdbcTemplate jdbcTemplate;
@Autowired
private JwtTokenProvider jwtTokenProvider;
public SdkAuthFilter(SdkJwtTokenProvider sdkJwtTokenProvider,
JwtTokenProvider jwtTokenProvider,
JdbcTemplate jdbcTemplate) {
this.sdkJwtTokenProvider = sdkJwtTokenProvider;
this.jwtTokenProvider = jwtTokenProvider;
this.jdbcTemplate = jdbcTemplate;
}
private static final ObjectMapper objectMapper = new ObjectMapper();
@ -61,8 +67,12 @@ public class SdkAuthFilter extends OncePerRequestFilter {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
// 拦截 /ai/ 路径和 /feedback 端点,排除静态资源和 swagger
return !path.startsWith("/ai/") && !path.equals("/feedback");
// /ai/system-config/** 是公开接口(供 SDK 匿名拉取声明内容),SecurityConfig 已声明 permitAll,此处保持一致
if (path.startsWith("/ai/system-config/")) {
return true;
}
// 拦截 /ai/ 路径、/feedback 端点,以及 SDK 上传接口 /attachment/upload,排除静态资源和 swagger
return !path.startsWith("/ai/") && !path.equals("/feedback") && !path.equals("/attachment/upload");
}
@Override
@ -106,9 +116,24 @@ public class SdkAuthFilter extends OncePerRequestFilter {
// 通道 B:管理后台 JWT 验证(仅当 SDK 验证失败时尝试)
if (claims == null) {
try {
if (jwtTokenProvider.validateToken(token)) {
// 管理后台 JWT 验证成功,设置 SecurityContext
if (jwtTokenProvider.validateAccessToken(token)) {
// 管理后台 JWT 验证成功,先校验服务端撤销状态(token_version + enabled)
String username = jwtTokenProvider.getUsernameFromToken(token);
int tokenVer = jwtTokenProvider.getTokenVersionFromToken(token);
// 与 JwtAuthFilter 保持一致:token_version 必须匹配且账号处于启用状态
Integer currentVer = jdbcTemplate.query(
"SELECT token_version FROM sys_user WHERE username = ? AND is_delete = false AND enabled = true",
rs -> rs.next() ? rs.getInt("token_version") : null,
username
);
if (currentVer == null || !currentVer.equals(tokenVer)) {
log.warn("管理后台 JWT 已失效或账号被禁用: path={}, user={}",
request.getServletPath(), username);
writeError(response, 401, "无效的认证令牌");
return;
}
List<String> roles = jwtTokenProvider.getRolesFromToken(token);
List<SimpleGrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))

18
src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java

@ -8,6 +8,7 @@ import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
@ -37,14 +38,23 @@ public class SdkJwtTokenProvider {
/** 5 分钟下限 */
private static final long MIN_EXPIRATION = 300000L;
/** 默认密钥标识(禁止使用),长度 ≥64 字符以兼容 HS256/HS384/HS512 */
private static final String DEFAULT_SECRET = "support-bot-sdk-jwt-secret-2026-please-change-to-a-strong-random-value";
/** 默认密钥标识(禁止使用),长度 ≥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:7200000}") long defaultExpiration,
@Value("${jwt.fail-if-default-sdk-secret:false}") boolean failIfDefaultSdkSecret) {
// 空值校验:密钥为空/空白时启动失败并给出明确提示,避免 Keys.hmacShaKeyFor 抛出晦涩异常
if (!StringUtils.hasText(sdkSecret)) {
throw new IllegalArgumentException("jwt.sdk-secret 不能为空,请通过环境变量 JWT_SDK_SECRET 配置强随机密钥");
}
if (DEFAULT_SECRET.equals(sdkSecret)) {
log.warn("⚠️ SDK JWT 使用了默认密钥,存在安全风险!请在 application.yml 中配置 jwt.sdk-secret 为强随机字符串");
String message = "⚠️ SDK JWT 使用了默认密钥,存在安全风险!请在 application.yml 中配置 jwt.sdk-secret 为强随机字符串(建议通过环境变量 JWT_SDK_SECRET 注入)";
// if (failIfDefaultSdkSecret) {
// throw new IllegalStateException(message);
// }
log.warn(message);
}
this.key = Keys.hmacShaKeyFor(sdkSecret.getBytes(StandardCharsets.UTF_8));
this.defaultExpiration = defaultExpiration;

47
src/main/java/com/wok/supportbot/security/SecurityConfig.java

@ -1,5 +1,6 @@
package com.wok.supportbot.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
@ -19,6 +20,7 @@ import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
/**
@ -31,9 +33,15 @@ import java.util.List;
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final List<String> allowedOrigins;
public SecurityConfig(JwtAuthFilter jwtAuthFilter) {
public SecurityConfig(JwtAuthFilter jwtAuthFilter,
@Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:5174,http://localhost:9090}") String allowedOrigins) {
this.jwtAuthFilter = jwtAuthFilter;
this.allowedOrigins = Arrays.stream(allowedOrigins.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
@Bean
@ -54,10 +62,13 @@ public class SecurityConfig {
.requestMatchers("/ai/**").permitAll()
// 开放 API(由 ApiKeyAuthFilter 独立鉴权)
.requestMatchers("/open-api/**").permitAll()
// 登录/刷新 Token
.requestMatchers("/auth/login", "/auth/refresh").permitAll()
// 登录/刷新 Token/退出登录
// logout 设为 permitAll:改密或禁用后 token 已失效,仍需能清除 refresh cookie
.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()
@ -117,14 +128,30 @@ public class SecurityConfig {
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("*"));
config.setAllowCredentials(true);
// SDK 第三方接入接口:开放跨域(第三方域名动态未知,使用 Bearer Token 鉴权,无 refresh cookie 暴露风险)
CorsConfiguration sdkConfig = new CorsConfiguration();
sdkConfig.setAllowedOriginPatterns(List.of("*"));
sdkConfig.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
sdkConfig.setAllowedHeaders(List.of("*"));
sdkConfig.setExposedHeaders(List.of("*"));
sdkConfig.setAllowCredentials(true);
// 管理后台接口:来源白名单(保护 httpOnly refresh cookie,防跨域 CSRF)
CorsConfiguration adminConfig = new CorsConfiguration();
adminConfig.setAllowedOriginPatterns(allowedOrigins);
adminConfig.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
adminConfig.setAllowedHeaders(List.of("*"));
adminConfig.setExposedHeaders(List.of("*"));
adminConfig.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
// 先注册精确的 SDK 路径,再注册兜底的 /**,保证 SDK 接口优先命中开放配置
source.registerCorsConfiguration("/ai/**", sdkConfig);
source.registerCorsConfiguration("/category/tree", sdkConfig);
source.registerCorsConfiguration("/category/list", sdkConfig);
source.registerCorsConfiguration("/feedback", sdkConfig);
source.registerCorsConfiguration("/attachment/upload", sdkConfig);
source.registerCorsConfiguration("/**", adminConfig);
return source;
}
}

9
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<String, Object> 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);
}

63
src/main/java/com/wok/supportbot/service/SysUserService.java

@ -7,6 +7,7 @@ import com.wok.supportbot.dao.SysUserRoleMapper;
import com.wok.supportbot.entity.SysRole;
import com.wok.supportbot.entity.SysUser;
import com.wok.supportbot.entity.SysUserRole;
import com.wok.supportbot.security.PasswordValidator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@ -33,15 +34,17 @@ public class SysUserService implements UserDetailsService {
private final SysRoleMapper roleMapper;
private final SysUserRoleMapper userRoleMapper;
private final PasswordEncoder passwordEncoder;
private final PasswordValidator passwordValidator;
private final JdbcTemplate jdbcTemplate;
public SysUserService(SysUserMapper userMapper, SysRoleMapper roleMapper,
SysUserRoleMapper userRoleMapper, PasswordEncoder passwordEncoder,
JdbcTemplate jdbcTemplate) {
PasswordValidator passwordValidator, JdbcTemplate jdbcTemplate) {
this.userMapper = userMapper;
this.roleMapper = roleMapper;
this.userRoleMapper = userRoleMapper;
this.passwordEncoder = passwordEncoder;
this.passwordValidator = passwordValidator;
this.jdbcTemplate = jdbcTemplate;
}
@ -86,7 +89,7 @@ public class SysUserService implements UserDetailsService {
}
/**
* 获取用户的角色列表
* 获取用户的角色列表(排除已软删除的角色)
*/
public List<SysRole> getUserRoles(Long userId) {
List<SysUserRole> userRoles = userRoleMapper.selectList(
@ -94,7 +97,12 @@ public class SysUserService implements UserDetailsService {
);
if (userRoles.isEmpty()) return List.of();
List<Long> roleIds = userRoles.stream().map(SysUserRole::getRoleId).toList();
return roleMapper.selectBatchIds(roleIds);
// 显式过滤已删除角色,避免已删除角色继续授予失效权限
return roleMapper.selectList(
new LambdaQueryWrapper<SysRole>()
.in(SysRole::getId, roleIds)
.eq(SysRole::getIsDelete, false)
);
}
/**
@ -165,6 +173,9 @@ public class SysUserService implements UserDetailsService {
throw new IllegalArgumentException("用户名已存在: " + username);
}
// 校验密码强度
passwordValidator.validate(password);
SysUser user = SysUser.builder()
.username(username)
.password(passwordEncoder.encode(password))
@ -172,6 +183,7 @@ public class SysUserService implements UserDetailsService {
.email(email)
.phone(phone)
.enabled(true)
.tokenVersion(0)
.build();
userMapper.insert(user);
@ -198,30 +210,60 @@ public class SysUserService implements UserDetailsService {
}
/**
* 修改密码
* 修改密码(会触发 token_version 自增,使已签发 token 失效)
*/
@Transactional(rollbackFor = Exception.class)
public void changePassword(Long id, String newPassword) {
SysUser user = userMapper.selectById(id);
if (user == null) throw new IllegalArgumentException("用户不存在");
// 校验密码强度
passwordValidator.validate(newPassword);
user.setPassword(passwordEncoder.encode(newPassword));
user.setTokenVersion((user.getTokenVersion() == null ? 0 : user.getTokenVersion()) + 1);
userMapper.updateById(user);
}
/**
* 启用/禁用用户
* 递增用户 token_version,使已签发的 access/refresh token 立即失效(登出、改密、禁用、角色变更时调用)
*/
@Transactional(rollbackFor = Exception.class)
public void incrementTokenVersion(Long userId) {
SysUser user = userMapper.selectById(userId);
if (user == null) return;
user.setTokenVersion((user.getTokenVersion() == null ? 0 : user.getTokenVersion()) + 1);
userMapper.updateById(user);
}
/**
* 启用/禁用用户(禁用会使已签发 token 失效)
*/
@Transactional(rollbackFor = Exception.class)
public void toggleUser(Long id, boolean enabled) {
SysUser user = userMapper.selectById(id);
if (user == null) throw new IllegalArgumentException("用户不存在");
user.setEnabled(enabled);
if (!enabled) {
user.setTokenVersion((user.getTokenVersion() == null ? 0 : user.getTokenVersion()) + 1);
}
userMapper.updateById(user);
}
/**
* 分配角色(先删后插)
* 分配角色(先删后插,角色变更会使已签发 token 中的角色信息失效)
*/
@Transactional(rollbackFor = Exception.class)
public void assignRoles(Long userId, List<Long> roleIds) {
// 查询旧角色集合,用于判断是否真正发生变更
List<Long> oldRoleIds = userRoleMapper.selectList(
new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getUserId, userId)
).stream()
.map(SysUserRole::getRoleId)
.sorted()
.toList();
List<Long> newRoleIds = roleIds.stream().sorted().toList();
// 删除旧关联
userRoleMapper.delete(
new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getUserId, userId)
@ -234,6 +276,15 @@ public class SysUserService implements UserDetailsService {
.build();
userRoleMapper.insert(userRole);
}
// 仅当角色集合真正变化时才递增 token_version,避免重复分配相同角色导致已签发 token 被误失效
if (!oldRoleIds.equals(newRoleIds)) {
SysUser user = userMapper.selectById(userId);
if (user != null) {
user.setTokenVersion((user.getTokenVersion() == null ? 0 : user.getTokenVersion()) + 1);
userMapper.updateById(user);
}
}
}
/**

15
src/main/resources/application-dev.yml

@ -13,16 +13,21 @@ spring:
url: jdbc:postgresql://192.168.1.49:5432/support_bot
username: postgres
password: supportbot123
sql:
init:
mode: never
schema-locations: classpath:support-bot.sql
continue-on-error: true
# ==================== Knife4j 开关 ====================
knife4j:
enable: true
# ==================== JWT 开发配置 ====================
jwt:
refresh-cookie-secure: false
fail-if-default-secret: false
# ==================== CORS 开发配置 ====================
app:
cors:
allowed-origins: http://localhost:5173,http://localhost:5174,http://localhost:9090
# ==================== 文件存储路径 ====================
knowledge:
storage:

16
src/main/resources/application-prod.yml

@ -21,17 +21,23 @@ spring:
url: jdbc:postgresql://192.168.1.220:5432/support_bot
username: admin
password: cdw147258369
sql:
init:
mode: never
schema-locations: classpath:support-bot.sql
continue-on-error: true
# ==================== Knife4j 开关 ====================
# 生产环境建议关闭 API 文档入口
knife4j:
enable: false
# ==================== JWT 安全配置(生产必须覆盖密钥并启用 Secure Cookie) ====================
jwt:
refresh-cookie-secure: true
fail-if-default-secret: true
fail-if-default-sdk-secret: true
# ==================== CORS 白名单(生产替换为真实域名) ====================
app:
cors:
allowed-origins: https://docfilepath.dgscdw.com:8804,https://aidoc.dgscdw.com:11300
# ==================== 文件存储路径 ====================
knowledge:
storage:

28
src/main/resources/application.yml

@ -122,13 +122,33 @@ logging:
# ==================== JWT 认证配置 ====================
jwt:
# 签名密钥(生产环境务必通过环境变量 JWT_SECRET 覆盖)
secret: support-bot-jwt-secret-key-2026-please-change-in-production
# 访问令牌有效期:24小时(毫秒)
expiration: 86400000
secret: ${JWT_SECRET:support-bot-jwt-secret-key-2026-please-change-in-production}
# 访问令牌有效期:15分钟(毫秒),降低 XSS 窃取后的利用窗口
expiration: 900000
# 刷新令牌有效期:7天(毫秒)
refresh-expiration: 604800000
# refresh token cookie 是否标记 Secure(生产必须 true,本地开发通过 application-dev.yml 覆盖为 false)
refresh-cookie-secure: true
# 使用默认密钥时是否直接启动失败(生产建议 true)
fail-if-default-secret: false
# ==================== SDK 专用 JWT 配置 ====================
# SDK Token 签名密钥(独立于管理后台 JWT 密钥,生产环境务必覆盖)
sdk-secret: support-bot-sdk-jwt-secret-2026-please-change
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
# ==================== 登录安全配置 ====================
security:
login:
# 连续登录失败锁定阈值
max-attempts: 5
# 锁定持续时长(分钟)
lock-minutes: 15
# ==================== CORS 配置 ====================
app:
cors:
# 允许携带 Cookie 的跨域来源(逗号分隔),生产替换为实际域名
allowed-origins: http://localhost:5173,http://localhost:5174,http://localhost:9090

222
src/main/resources/init-database.sql

@ -1,7 +1,8 @@
-- ============================================================
-- AI 智能客服系统 - 数据库初始化脚本(完整版)
-- 适用环境: PostgreSQL 12+ 且已安装 pgvector 扩展
-- 表数量: 14 张(基础 8 张 + P0 阶段新增 6 张)
-- 表数量: 27 张(含 vector_store;其余与 DatabaseInitConfig 的 26 张 expectedTables 对齐)
-- 注: vector_store 由 Spring AI 自动建表,此处手动建表作为备份方案
--
-- ⚠️ 开发规范:任何涉及建表、增删改列、初始数据的变更,
-- 必须同时更新本脚本和 DatabaseInitConfig.java(自动初始化),
@ -33,21 +34,21 @@ CREATE EXTENSION IF NOT EXISTS vector;
-- 表 1: chat_message — 聊天消息表
-- ============================================================
CREATE TABLE IF NOT EXISTS chat_message (
id BIGINT PRIMARY KEY,
id BIGSERIAL PRIMARY KEY,
conversation_id VARCHAR(64) NOT NULL,
message_type VARCHAR(20) NOT NULL,
content TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
user_id BIGINT,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_delete BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT chk_message_type CHECK (message_type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL'))
is_delete BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_chat_message_conversation_id ON chat_message (conversation_id);
CREATE INDEX IF NOT EXISTS idx_chat_message_create_time ON chat_message (create_time DESC);
CREATE INDEX IF NOT EXISTS idx_chat_message_type ON chat_message (message_type);
CREATE INDEX IF NOT EXISTS idx_chat_message_not_deleted ON chat_message (conversation_id) WHERE is_delete = FALSE;
CREATE INDEX IF NOT EXISTS idx_chat_message_user_id ON chat_message (user_id);
COMMENT ON TABLE chat_message IS '聊天消息表';
COMMENT ON COLUMN chat_message.id IS '主键(雪花算法生成)';
@ -55,6 +56,7 @@ COMMENT ON COLUMN chat_message.conversation_id IS '会话ID';
COMMENT ON COLUMN chat_message.message_type IS '消息类型: USER / ASSISTANT / SYSTEM';
COMMENT ON COLUMN chat_message.content IS '消息内容';
COMMENT ON COLUMN chat_message.metadata IS '元数据(JSON)';
COMMENT ON COLUMN chat_message.user_id IS '所属系统用户ID(数据隔离,SDK调用时为null)';
COMMENT ON COLUMN chat_message.create_time IS '创建时间';
COMMENT ON COLUMN chat_message.update_time IS '更新时间';
COMMENT ON COLUMN chat_message.is_delete IS '逻辑删除: FALSE=正常 TRUE=已删除';
@ -165,19 +167,30 @@ COMMENT ON COLUMN customer_service_role.update_time IS '更新时间';
COMMENT ON COLUMN customer_service_role.is_delete IS '逻辑删除';
COMMENT ON COLUMN customer_service_role.allowed_mcp_tools IS '允许使用的 MCP 工具列表(JSONB 数组,空=不允许,["*"]=全部允许)';
-- 默认角色种子数据
INSERT INTO customer_service_role (role_key, name, description, enabled)
VALUES ('general', '客服', '通用客服角色', TRUE)
-- 默认角色种子数据(与 DatabaseInitConfig.syncDefaultCustomerServiceRoles 保持一致,含 prompt)
INSERT INTO customer_service_role (role_key, name, description, prompt, enabled)
VALUES ('general', '客服', '用户咨询、业务办理、常见问题、问题受理与进度说明',
'职责:回答用户关于业务办理、服务流程、常见问题、问题受理和进度说明的问题。
要求:优先依据知识库回答;资料不足时说明无法确认,并引导用户补充必要信息;涉及财务、行政制度细节时不要猜测,应提示转对应角色处理。', TRUE)
ON CONFLICT (role_key) DO NOTHING;
INSERT INTO customer_service_role (role_key, name, description, enabled)
VALUES ('finance', '财务', '财务相关客服角色', TRUE)
INSERT INTO customer_service_role (role_key, name, description, prompt, enabled)
VALUES ('finance', '财务', '付款、退款、发票、对账、报销、结算与费用规则',
'职责:回答付款、退款、发票、对账、报销、结算、费用规则相关问题。
要求:金额、账户、票据、时间节点必须严谨;知识库没有依据时不得编造政策;需要用户提供单号、金额、日期、发票抬头等关键信息。', TRUE)
ON CONFLICT (role_key) DO NOTHING;
INSERT INTO customer_service_role (role_key, name, description, enabled)
VALUES ('administration', '行政', '行政相关客服角色', TRUE)
INSERT INTO customer_service_role (role_key, name, description, prompt, enabled)
VALUES ('administration', '行政', '办公制度、行政流程、资产、会议、考勤、用章、采购与后勤',
'职责:回答办公制度、行政流程、资产、会议、考勤、入职、用章、采购、后勤等问题。
要求:优先依据公司制度和流程文件回答;涉及审批权限、特殊例外或未覆盖场景时提示按制度提交申请或联系行政负责人。', TRUE)
ON CONFLICT (role_key) DO NOTHING;
-- 废弃旧角色(与 DatabaseInitConfig.retireObsoleteDefaultRoles 保持一致)
UPDATE customer_service_role
SET is_delete = true, enabled = false, update_time = CURRENT_TIMESTAMP
WHERE role_key IN ('after_sale', 'logistics', 'product');
-- ============================================================
-- 表 5: customer_service_role_category — 角色知识库关联表
-- ============================================================
@ -308,8 +321,8 @@ COMMENT ON COLUMN mcp_server_config.is_delete IS '逻辑删除: FALSE=正常 TR
-- 表 8: vector_store — 向量存储表
-- 注意: Spring AI PgVectorStore 配置了 initializeSchema(true),
-- 应用启动时会自动建表。此处手动建表作为备份方案。
-- 向量维度默认 1024,如使用不同维度的 Embedding 模型
-- 需修改下面的 VECTOR(1024) 并重建。
-- ⚠️ 向量维度必须与 application.yml 的 knowledge.vector.dimension 保持一致(默认 1024)。
-- 若修改了该配置(如 1536/2048),需同步修改下面的 VECTOR(1024) 与 faq_embedding 的 vector(1024),并重建表。
-- ============================================================
CREATE TABLE IF NOT EXISTS vector_store (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
@ -583,6 +596,56 @@ COMMENT ON COLUMN faq_embedding.embedding IS '向量嵌入(维度由 knowled
COMMENT ON COLUMN faq_embedding.model_name IS '使用的 Embedding 模型名称(用于检测模型变更后重新计算)';
COMMENT ON COLUMN faq_embedding.create_time IS '创建时间';
-- ============================================================
-- 表 15: api_key — API Key 表(P1-003 开放平台)
-- ============================================================
CREATE TABLE IF NOT EXISTS api_key (
id BIGSERIAL PRIMARY KEY,
key_value VARCHAR(128) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
user_id BIGINT,
rate_limit INTEGER DEFAULT 60 NOT NULL,
max_calls BIGINT,
current_calls BIGINT DEFAULT 0 NOT NULL,
expire_time TIMESTAMP,
enabled BOOLEAN DEFAULT TRUE 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
);
CREATE INDEX IF NOT EXISTS idx_api_key_user ON api_key (user_id);
CREATE INDEX IF NOT EXISTS idx_api_key_enabled ON api_key (enabled) WHERE is_delete = false;
COMMENT ON TABLE api_key IS 'API Key 表(第三方系统接入鉴权)';
COMMENT ON COLUMN api_key.key_value IS 'API Key 值(唯一,用于换取 SDK JWT)';
COMMENT ON COLUMN api_key.user_id IS '所属系统用户 ID';
COMMENT ON COLUMN api_key.rate_limit IS '速率限制(每分钟调用次数)';
-- ============================================================
-- 表 16: webhook_config — Webhook 配置表(P1-003 开放平台)
-- ============================================================
CREATE TABLE IF NOT EXISTS webhook_config (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
name VARCHAR(100) NOT NULL,
url VARCHAR(512) NOT NULL,
events JSONB DEFAULT '[]' NOT NULL,
enabled BOOLEAN DEFAULT TRUE NOT NULL,
secret VARCHAR(128),
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
is_delete BOOLEAN DEFAULT FALSE NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webhook_user ON webhook_config (user_id);
CREATE INDEX IF NOT EXISTS idx_webhook_enabled ON webhook_config (enabled) WHERE is_delete = false;
COMMENT ON TABLE webhook_config IS 'Webhook 回调配置表';
COMMENT ON COLUMN webhook_config.url IS '回调地址';
COMMENT ON COLUMN webhook_config.events IS '订阅的事件类型(JSONB 数组)';
-- ============================================================
-- 清理租户相关数据(移除多租户架构)
-- ============================================================
@ -740,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;
@ -758,9 +811,122 @@ INSERT INTO system_config (config_key, config_value, description)
VALUES ('llm_trace_retention_days', '30', 'LLM 调用追踪记录保留天数(自动清理,默认 30 天)')
ON CONFLICT (config_key) DO NOTHING;
-- ============================================================
-- 表 17-21: 用户认证与登录安全(与 DatabaseInitConfig 同步)
-- ============================================================
CREATE TABLE IF NOT EXISTS sys_user (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password VARCHAR(256) NOT NULL,
nickname VARCHAR(100),
email VARCHAR(128),
phone VARCHAR(20),
avatar VARCHAR(512),
enabled BOOLEAN DEFAULT TRUE NOT NULL,
token_version INT DEFAULT 0 NOT NULL,
last_login_time TIMESTAMP,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
is_delete BOOLEAN DEFAULT FALSE NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sys_user_username ON sys_user (username) WHERE is_delete = false;
CREATE INDEX IF NOT EXISTS idx_sys_user_enabled ON sys_user (enabled) WHERE is_delete = false;
COMMENT ON COLUMN sys_user.token_version IS 'JWT 令牌版本号,改密/禁用/角色变更时自增,用于服务端撤销已签发 token';
CREATE TABLE IF NOT EXISTS sys_role (
id BIGSERIAL PRIMARY KEY,
role_key VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
enabled BOOLEAN DEFAULT TRUE 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
);
CREATE TABLE IF NOT EXISTS sys_permission (
id BIGSERIAL PRIMARY KEY,
permission_key VARCHAR(128) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
is_delete BOOLEAN DEFAULT FALSE NOT NULL
);
CREATE TABLE IF NOT EXISTS sys_user_role (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
UNIQUE (user_id, role_id)
);
CREATE INDEX IF NOT EXISTS idx_sys_user_role_user ON sys_user_role (user_id);
CREATE INDEX IF NOT EXISTS idx_sys_user_role_role ON sys_user_role (role_id);
CREATE TABLE IF NOT EXISTS login_attempt (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
failed_count INT DEFAULT 0 NOT NULL,
last_failed_time TIMESTAMP,
locked_until TIMESTAMP,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_login_attempt_locked ON login_attempt (locked_until);
-- ============================================================
-- 表 26: rag_hit_log — RAG 命中记录表(P1-002 运营数据分析看板)
-- ============================================================
CREATE TABLE IF NOT EXISTS rag_hit_log (
id BIGSERIAL PRIMARY KEY,
conversation_id VARCHAR(64),
user_query TEXT NOT NULL,
document_id BIGINT,
document_title VARCHAR(500),
chunk_id VARCHAR(128),
score DOUBLE PRECISION,
search_mode VARCHAR(20),
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rag_hit_log_created ON rag_hit_log (create_time DESC);
CREATE INDEX IF NOT EXISTS idx_rag_hit_log_document ON rag_hit_log (document_id);
COMMENT ON TABLE rag_hit_log IS 'RAG 命中记录表(记录每次知识库检索的命中/未命中情况)';
COMMENT ON COLUMN rag_hit_log.document_id IS '命中文档 ID(未命中时为 null)';
COMMENT ON COLUMN rag_hit_log.score IS '相似度分数';
COMMENT ON COLUMN rag_hit_log.search_mode IS '检索模式: VECTOR / KEYWORD / HYBRID';
-- ============================================================
-- 表 27: dashboard_snapshot — 运营看板每日汇总快照(P1-002)
-- ============================================================
CREATE TABLE IF NOT EXISTS dashboard_snapshot (
id BIGSERIAL PRIMARY KEY,
snapshot_date DATE NOT NULL UNIQUE,
conversation_count INTEGER DEFAULT 0 NOT NULL,
message_count INTEGER DEFAULT 0 NOT NULL,
satisfaction_rate DOUBLE PRECISION DEFAULT 0,
thumbs_up_count INTEGER DEFAULT 0 NOT NULL,
thumbs_down_count INTEGER DEFAULT 0 NOT NULL,
rag_hit_count INTEGER DEFAULT 0 NOT NULL,
rag_miss_count INTEGER DEFAULT 0 NOT NULL,
avg_response_time DOUBLE PRECISION DEFAULT 0,
top_questions JSONB DEFAULT '[]' NOT NULL,
top_hit_documents JSONB DEFAULT '[]' NOT NULL,
miss_questions JSONB DEFAULT '[]' NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_snapshot_date ON dashboard_snapshot (snapshot_date DESC);
COMMENT ON TABLE dashboard_snapshot IS '运营看板每日汇总快照';
COMMENT ON COLUMN dashboard_snapshot.snapshot_date IS '快照日期(唯一,每天一条)';
COMMENT ON COLUMN dashboard_snapshot.conversation_count IS '当日会话数';
COMMENT ON COLUMN dashboard_snapshot.satisfaction_rate IS '满意度(0~1)';
-- ============================================================
-- 完成!
-- 注:本脚本与 DatabaseInitConfig 为双文件同步源;部分表(如 rag_hit_log、
-- dashboard_snapshot、sys_user 等)历史上仅随 DatabaseInitConfig 演进,
-- 属既有技术债,以 DatabaseInitConfig 为准。
-- 注:本脚本与 DatabaseInitConfig 为双文件同步源,以 DatabaseInitConfig 为准。
-- ============================================================

6
src/main/resources/static/sdk/test.html

@ -4,12 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatbotSDK 测试面板</title>
<script type="module" crossorigin src="/assets/sdk-test-BV9sVPfh.js"></script>
<script type="module" crossorigin src="/assets/sdk-test-BM4QMGvl.js"></script>
<link rel="modulepreload" crossorigin href="/assets/tdesign-BEIBDEDo.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-web-components-BtnmJ2z8.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-chat-CvvLoh5G.js">
<link rel="modulepreload" crossorigin href="/assets/markdown-Dyjv1-p0.js">
<link rel="modulepreload" crossorigin href="/assets/chatAdapter-DFdkvd4y.js">
<link rel="modulepreload" crossorigin href="/assets/markdown-pxFwF6hE.js">
<link rel="modulepreload" crossorigin href="/assets/chatAdapter-DJkCuBX0.js">
<link rel="stylesheet" crossorigin href="/assets/tdesign-CY0HVqZ3.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-chat-Dj1Q23QO.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-web-components-B-ycfzW_.css">

16
src/main/resources/support-bot.sql

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

Loading…
Cancel
Save