加载中...
';try{const e=await $(1,50,pn.userId,pn.integrateId);Ln=e.list.map(n=>({id:n.conversationId||"",chatId:n.conversationId||"",messageCount:n.messageCount,lastMessageTime:n.lastMessageTime,lastMessagePreview:n.lastMessagePreview,createdAt:n.firstMessageTime||n.createdAt})),Sn="",wn&&(wn.value=""),Z(n,Ln,n=>{On(n)},n=>{window.open(S(n),"_blank")},async n=>{confirm(m("history_delete_confirm"))&&await L(n)&&(n===f()&&(un=[],kn&&kn.querySelectorAll(".csk-msg, .csk-loading").forEach(n=>n.remove()),bn&&(bn.style.display="none"),Hn()),Ln=Ln.filter(e=>(e.chatId||e.id)!==n),qn())},f())}catch(e){o.error(m("history_load_error"),e),n.innerHTML=` {
+ if (!currentConfig) return false;
+ const url = buildUrl('/feedback');
+ try {
+ const response = await safeFetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ messageId: String(messageId),
+ conversationId: currentConfig.chatId,
+ feedbackType,
+ }),
+ });
+ if (!response.ok) {
+ logger.error(`反馈提交失败 status=${response.status}`);
+ return false;
+ }
+ const json: ApiResponse = await response.json();
+ return json.success || false;
+ } catch (err) {
+ logger.error('反馈提交异常', err);
+ return false;
+ }
+}
+
// ==================== P2: 会话管理 + chatId 初始化 ====================
/** 会话列表项 */
diff --git a/client/src/chat.ts b/client/src/chat.ts
index b432c97..ae247f5 100644
--- a/client/src/chat.ts
+++ b/client/src/chat.ts
@@ -20,6 +20,7 @@ import {
updateChatId,
getChatId,
saveCachedChatId,
+ submitFeedbackApi,
CskError,
} from './api';
import {
@@ -342,8 +343,7 @@ function announceMessage(text: string): void {
/**
* 处理消息反馈:切换 AI 消息的点赞/点踩状态
- * 当前仅前端状态持久化(存入 messages 数组 + localStorage),
- * 预留后端对接位:TODO 接口 POST /conversation/message/feedback
+ * 前端状态持久化(存入 messages 数组 + localStorage)+ 调用后端 API 记录反馈
*/
export function handleFeedback(msgId: string, value: 'up' | 'down'): void {
if (!messagesContainer) return;
@@ -362,7 +362,19 @@ export function handleFeedback(msgId: string, value: 'up' | 'down'): void {
// 持久化(localStorage)
if (config) saveMessages(config.integrateId, messages);
- logger.info(`消息反馈 msgId=${msgId} value=${newValue || 'cleared'}`);
+ // 调用后端 API 记录反馈
+ if (newValue) {
+ const feedbackType = newValue === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN';
+ submitFeedbackApi(String(msgId), feedbackType).then(success => {
+ if (success) {
+ logger.info(`消息反馈已提交 msgId=${msgId} value=${newValue}`);
+ } else {
+ logger.warn(`消息反馈提交失败 msgId=${msgId}(本地状态已更新)`);
+ }
+ });
+ } else {
+ logger.info(`消息反馈已取消 msgId=${msgId}`);
+ }
}
function autoResizeInput(): void {
if (!inputEl) return;
diff --git a/src/main/java/com/wok/supportbot/advisor/ContentSafetyAdvisor.java b/src/main/java/com/wok/supportbot/advisor/ContentSafetyAdvisor.java
new file mode 100644
index 0000000..8201889
--- /dev/null
+++ b/src/main/java/com/wok/supportbot/advisor/ContentSafetyAdvisor.java
@@ -0,0 +1,228 @@
+package com.wok.supportbot.advisor;
+
+import com.wok.supportbot.dao.ContentAuditLogMapper;
+import com.wok.supportbot.entity.ContentAuditLog;
+import com.wok.supportbot.service.ContentSafetyService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.client.ChatClientRequest;
+import org.springframework.ai.chat.client.ChatClientResponse;
+import org.springframework.ai.chat.client.advisor.api.AdvisorChain;
+import org.springframework.ai.chat.client.advisor.api.BaseAdvisor;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.Message;
+import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.model.Generation;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.Ordered;
+import org.springframework.stereotype.Component;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 内容安全过滤 Advisor
+ *
+ * 在对话链路的最外层拦截,对用户输入和 AI 输出进行敏感词检测与脱敏/拦截处理。
+ * 使用 Ordered.HIGHEST_PRECEDENCE 确保最先执行 before、最后执行 after。
+ */
+@Slf4j
+@Component
+public class ContentSafetyAdvisor implements BaseAdvisor {
+
+ /** 安全提示文本 —— 当用户消息被 BLOCK 时替换原始内容 */
+ private static final String SAFE_USER_PROMPT = "系统检测到不当内容";
+
+ /** 安全提示文本 —— 当 AI 输出被拦截时替换原始内容 */
+ private static final String SAFE_AI_RESPONSE = "抱歉,该内容无法展示。如有问题请联系管理员。";
+
+ @Autowired
+ private ContentSafetyService contentSafetyService;
+
+ @Autowired
+ private ContentAuditLogMapper contentAuditLogMapper;
+
+ @Override
+ public String getName() {
+ return this.getClass().getSimpleName();
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.HIGHEST_PRECEDENCE;
+ }
+
+ // ==================== before:检查用户输入 ====================
+
+ @Override
+ public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
+ try {
+ Prompt originalPrompt = request.prompt();
+ List messages = new ArrayList<>(originalPrompt.getInstructions());
+
+ // 提取最后一条用户消息进行检测
+ String userText = null;
+ int lastUserIndex = -1;
+ for (int i = messages.size() - 1; i >= 0; i--) {
+ if (messages.get(i) instanceof UserMessage) {
+ userText = messages.get(i).getText();
+ lastUserIndex = i;
+ break;
+ }
+ }
+
+ if (userText == null || userText.isBlank()) {
+ return request;
+ }
+
+ // 执行 DFA 检测
+ List hits = contentSafetyService.detect(userText);
+ if (hits.isEmpty()) {
+ return request;
+ }
+
+ boolean hasBlocking = contentSafetyService.hasBlockingHit(hits);
+ String sessionId = extractSessionId(request);
+
+ if (hasBlocking) {
+ // BLOCK 级别:替换用户消息为安全提示,记录审计日志
+ log.warn("内容安全拦截(BLOCK)- 会话:{},命中词:{}", sessionId,
+ hits.stream().map(ContentSafetyService.HitResult::getWord).collect(Collectors.joining(", ")));
+
+ messages.set(lastUserIndex, new UserMessage(SAFE_USER_PROMPT));
+ Prompt newPrompt = new Prompt(messages, originalPrompt.getOptions());
+
+ saveAuditLog(sessionId, "INPUT", truncate(userText), hits, "BLOCK");
+
+ return ChatClientRequest.builder()
+ .prompt(newPrompt)
+ .context(request.context())
+ .build();
+ } else {
+ // WARN 级别:脱敏后替换用户消息,记录审计日志
+ String maskedText = contentSafetyService.mask(userText);
+ log.info("内容安全脱敏(MASK)- 会话:{},命中词:{}", sessionId,
+ hits.stream().map(ContentSafetyService.HitResult::getWord).collect(Collectors.joining(", ")));
+
+ messages.set(lastUserIndex, new UserMessage(maskedText));
+ Prompt newPrompt = new Prompt(messages, originalPrompt.getOptions());
+
+ saveAuditLog(sessionId, "INPUT", truncate(userText), hits, "MASK");
+
+ return ChatClientRequest.builder()
+ .prompt(newPrompt)
+ .context(request.context())
+ .build();
+ }
+ } catch (Exception e) {
+ log.error("内容安全 before 检查异常,放行请求", e);
+ return request;
+ }
+ }
+
+ // ==================== after:检查 AI 输出 ====================
+
+ @Override
+ public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) {
+ try {
+ ChatResponse chatResponse = response.chatResponse();
+ if (chatResponse == null || chatResponse.getResult() == null) {
+ return response;
+ }
+
+ String aiText = chatResponse.getResult().getOutput().getText();
+ if (aiText == null || aiText.isBlank()) {
+ return response;
+ }
+
+ // 执行 DFA 检测
+ List hits = contentSafetyService.detect(aiText);
+ if (hits.isEmpty()) {
+ return response;
+ }
+
+ boolean hasBlocking = contentSafetyService.hasBlockingHit(hits);
+ String actionTaken = hasBlocking ? "BLOCK" : "MASK";
+
+ log.warn("AI 输出内容安全拦截({})- 命中词:{}", actionTaken,
+ hits.stream().map(ContentSafetyService.HitResult::getWord).collect(Collectors.joining(", ")));
+
+ // 构建替换后的 AI 响应
+ String safeText = hasBlocking ? SAFE_AI_RESPONSE : contentSafetyService.mask(aiText);
+ AssistantMessage safeAssistantMessage = new AssistantMessage(safeText);
+ Generation safeGeneration = new Generation(safeAssistantMessage, chatResponse.getResult().getMetadata());
+ ChatResponse safeChatResponse = new ChatResponse(
+ Collections.singletonList(safeGeneration),
+ chatResponse.getMetadata()
+ );
+
+ // 保存审计日志(此处 sessionId 可能为空,因为 after 阶段不一定能获取到)
+ saveAuditLog("", "OUTPUT", truncate(aiText), hits, actionTaken);
+
+ return ChatClientResponse.builder()
+ .chatResponse(safeChatResponse)
+ .context(response.context())
+ .build();
+ } catch (Exception e) {
+ log.error("内容安全 after 检查异常,放行响应", e);
+ return response;
+ }
+ }
+
+ // ==================== 辅助方法 ====================
+
+ /**
+ * 从请求上下文中提取会话ID
+ */
+ private String extractSessionId(ChatClientRequest request) {
+ Map context = request.context();
+ if (context != null && context.containsKey("chatMemoryConversationId")) {
+ return String.valueOf(context.get("chatMemoryConversationId"));
+ }
+ return "";
+ }
+
+ /**
+ * 截断文本至指定长度
+ */
+ private String truncate(String text) {
+ if (text == null) {
+ return "";
+ }
+ return text.length() > 50 ? text.substring(0, 50) + "..." : text;
+ }
+
+ /**
+ * 保存审计日志
+ */
+ private void saveAuditLog(String sessionId, String direction, String originalText,
+ List hits, String actionTaken) {
+ try {
+ // 将命中结果转为 Map 列表存入 JSONB
+ List