package com.wok.supportbot.controller; import com.wok.supportbot.app.AssistantApp; import com.wok.supportbot.config.RoleAccessConfig; import com.wok.supportbot.service.ConversationService; import com.wok.supportbot.service.CustomerServiceRoleService; import com.wok.supportbot.service.CustomerServiceRoleService.RoleScope; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.document.Document; import org.springframework.http.MediaType; import org.springframework.http.codec.ServerSentEvent; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import reactor.core.publisher.Flux; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @RestController @Slf4j @RequestMapping("/ai") public class AiController { @Resource private AssistantApp assistantApp; @Resource private CustomerServiceRoleService customerServiceRoleService; @Resource private ConversationService conversationService; @Resource private RoleAccessConfig roleAccessConfig; /** * 同步调用 AI 智能客服应用 * * @param message 用户消息 * @param chatId 会话ID * @param roleId 客服角色ID(可选);命中角色时由服务端强制套用该角色人设 * @param systemPrompt 角色人设兜底(仅在未命中角色时生效) * @return AI 回答 */ @GetMapping("/assistant_app/chat/sync") public String doChatWithAssistantAppSync(String message, String chatId, Long roleId, String accountId, String systemPrompt) { AccountRoleContext context = resolveAccountRole(accountId, roleId); bindConversation(chatId, context); RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); return assistantApp.doChat(message, chatId, resolveSystemPrompt(scope, systemPrompt)); } /** * SSE 流式调用 AI 智能客服应用 * 返回Flux 响应式؜对象,并且添加 SSE 对应的 MediaType * * @param message * @param chatId * @return */ @GetMapping(value = "/assistant_app/chat/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux doChatWithLoveAppSSE(String message, String chatId, Long roleId, String accountId, String systemPrompt) { AccountRoleContext context = resolveAccountRole(accountId, roleId); bindConversation(chatId, context); RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); return assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt)); } /** * SSE 流式调用 AI 智能客服应用 * 返回 Flux 对象,并且؜设置泛型为 ServerSentEvent。使用这种方式可以省略 MediaType * * @param message * @param chatId * @return */ @GetMapping(value = "/assistant_app/chat/server_sent_event") public Flux> doChatWithAssistantAppServerSentEvent(String message, String chatId, Long roleId, String accountId, String systemPrompt) { AccountRoleContext context = resolveAccountRole(accountId, roleId); bindConversation(chatId, context); RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); return assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt)) .map(chunk -> ServerSentEvent.builder() .data(chunk) .build()); } /** * SSE 流式调用 AI 智能客服应用 * 使用 SSEEmiter,؜通过 send 方法持续向 SseEmitter 发送消息 * * @param message * @param chatId * @return */ @GetMapping(value = "/assistant_app/chat/sse_emitter") public SseEmitter doChatWithAssistantAppServerSseEmitter(String message, String chatId, Long roleId, String accountId, String systemPrompt) { AccountRoleContext context = resolveAccountRole(accountId, roleId); bindConversation(chatId, context); RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); // 创建一个超时时间较长的 SseEmitter SseEmitter sseEmitter = new SseEmitter(180000L); // 3 分钟超时 // 获取 Flux 响应式数据流并且直接通过订阅推送给 SseEmitter assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt)) .subscribe(chunk -> { try { sseEmitter.send(chunk); } catch (IOException e) { sseEmitter.completeWithError(e); } }, sseEmitter::completeWithError, sseEmitter::complete); // 返回 return sseEmitter; } /** * RAG 知识库同步对话(支持查询重写策略) * * @param message 用户消息 * @param chatId 会话ID * @param rewriteStrategy 查询重写策略(可选):NONE/REWRITE/TRANSLATION/COMPRESSION/MULTI_QUERY,默认为 MULTI_QUERY * @param roleId 客服角色ID(可选);命中角色时由服务端强制限定检索范围与人设,客户端无法跨域 * @param categoryId 单个分类过滤(仅未命中角色时生效) * @param categoryIds 多个分类过滤,逗号分隔(仅未命中角色时生效) * @param systemPrompt 角色人设兜底(仅未命中角色时生效) * @return AI 回答 */ @GetMapping("/assistant_app/chat/rag/sync") public String doChatWithRagSync(String message, String chatId, String rewriteStrategy, Long roleId, String accountId, Long categoryId, String categoryIds, String systemPrompt) { AccountRoleContext context = resolveAccountRole(accountId, roleId); bindConversation(chatId, context); RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); String sys = resolveSystemPrompt(scope, systemPrompt); // 严格隔离:未授权任何知识库的角色退回普通对话,绝不检索 KB if (isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) { return assistantApp.doChat(message, chatId, sys); } try { return assistantApp.doChatWithRagStrategy( message, chatId, normalizeStrategy(rewriteStrategy), resolveCategoryIds(scope, categoryId, categoryIds), sys); } catch (Exception e) { log.error("RAG 对话失败 [strategy={}, chatId={}]: {}", rewriteStrategy, chatId, e.getMessage(), e); return "抱歉,知识库检索出现异常,请稍后重试。"; } } /** * RAG 知识库流式对话(SSE,支持查询重写策略) * * @param message 用户消息 * @param chatId 会话ID * @param rewriteStrategy 查询重写策略(可选):NONE/REWRITE/TRANSLATION/COMPRESSION/MULTI_QUERY,默认为 MULTI_QUERY * @param roleId 客服角色ID(可选);命中角色时由服务端强制限定检索范围与人设,客户端无法跨域 * @param categoryId 单个分类过滤(仅未命中角色时生效) * @param categoryIds 多个分类过滤,逗号分隔(仅未命中角色时生效) * @param systemPrompt 角色人设兜底(仅未命中角色时生效) * @return 流式 AI 回答 */ @GetMapping(value = "/assistant_app/chat/rag/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux doChatWithRagSSE(String message, String chatId, String rewriteStrategy, Long roleId, String accountId, Long categoryId, String categoryIds, String systemPrompt) { AccountRoleContext context = resolveAccountRole(accountId, roleId); bindConversation(chatId, context); RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); String sys = resolveSystemPrompt(scope, systemPrompt); // 严格隔离:未授权任何知识库的角色退回普通流式对话,绝不检索 KB if (isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) { return assistantApp.doChatByStream(message, chatId, sys); } return assistantApp.doChatWithRagStrategyByStream( message, chatId, normalizeStrategy(rewriteStrategy), resolveCategoryIds(scope, categoryId, categoryIds), sys); } /** * RAG 引用来源:返回本次问题命中的知识库片段(不生成回答),供回答下方展示来源。 * 复用与 RAG 回答相同的角色范围、分类过滤与查询改写策略,确保来源即答案所依据的片段。 */ @GetMapping("/assistant_app/rag/sources") public Map getRagSources(String message, String chatId, String rewriteStrategy, Long roleId, String accountId, Long categoryId, String categoryIds) { AccountRoleContext context = resolveAccountRole(accountId, roleId); RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); if (message == null || message.isBlank() || isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) { return Map.of("success", true, "data", List.of()); } try { List cats = resolveCategoryIds(scope, categoryId, categoryIds); List docs = assistantApp.retrieveRagSources(message, chatId, normalizeStrategy(rewriteStrategy), cats); List> out = new ArrayList<>(); for (Document doc : docs) { Map meta = doc.getMetadata(); Map item = new LinkedHashMap<>(); item.put("documentId", meta.get("documentId")); item.put("title", meta.get("title")); item.put("sourceName", meta.get("sourceName")); item.put("chunkIndex", meta.get("chunkIndex")); item.put("score", meta.get("distance")); String text = doc.getText(); item.put("snippet", text != null && text.length() > 160 ? text.substring(0, 160) + "…" : text); out.add(item); } return Map.of("success", true, "data", out); } catch (Exception e) { log.error("获取 RAG 引用来源失败 [strategy={}]: {}", rewriteStrategy, e.getMessage(), e); return Map.of("success", true, "data", List.of()); } } // ==================== SDK 会话管理接口(带账户归属校验) ==================== /** * SDK 获取会话列表(分页,强制按账户 + 角色过滤) * 仅返回属于指定 accountId + roleId 的会话,防止跨账户数据泄露。 * * @param page 页码(默认1) * @param size 每页大小(默认20) * @param accountId 外部账户 ID(SDK 的 userId,必传) * @param roleId 角色 ID(SDK 的 integrateId,必传) * @return 分页会话列表 */ @GetMapping("/sdk/conversation/list") public Map sdkListConversations( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam String accountId, @RequestParam Long roleId) { try { Map result = conversationService.listConversationsForSdk(page, size, accountId, roleId); Map data = new java.util.HashMap<>(); data.put("success", true); data.put("data", result.get("records")); data.put("total", result.get("total")); data.put("page", result.get("page")); data.put("size", result.get("size")); data.put("pages", result.get("pages")); return data; } catch (Exception e) { log.error("SDK 会话列表查询失败: accountId={}, roleId={}", accountId, roleId, e); return Map.of("success", false, "message", "查询失败:" + e.getMessage()); } } /** * SDK 获取会话消息(含所有权校验) * 先验证会话是否属于指定 accountId + roleId,通过后才返回消息。 * * @param conversationId 会话 ID * @param accountId 外部账户 ID(必传) * @param roleId 角色 ID(必传) * @return 消息列表 */ @GetMapping("/sdk/conversation/{id}/messages") public Map sdkGetConversationMessages( @PathVariable("id") String conversationId, @RequestParam String accountId, @RequestParam Long roleId) { try { List> messages = conversationService.getConversationMessagesForSdk(conversationId, accountId, roleId); return Map.of("success", true, "data", messages, "total", messages.size()); } catch (IllegalArgumentException e) { return Map.of("success", false, "message", e.getMessage()); } catch (Exception e) { log.error("SDK 会话消息查询失败: conversationId={}, accountId={}", conversationId, accountId, e); return Map.of("success", false, "message", "查询失败:" + e.getMessage()); } } /** * SDK 删除会话(含所有权校验) * 仅允许删除属于自己的会话。 * * @param conversationId 会话 ID * @param accountId 外部账户 ID(必传) * @param roleId 角色 ID(必传) * @return 删除结果 */ @DeleteMapping("/sdk/conversation/{id}") public Map sdkDeleteConversation( @PathVariable("id") String conversationId, @RequestParam String accountId, @RequestParam Long roleId) { try { int count = conversationService.deleteConversationForSdk(conversationId, accountId, roleId); return Map.of("success", true, "message", "会话删除成功", "deletedMessages", count); } catch (IllegalArgumentException e) { return Map.of("success", false, "message", e.getMessage()); } catch (Exception e) { log.error("SDK 会话删除失败: conversationId={}, accountId={}", conversationId, accountId, e); return Map.of("success", false, "message", "删除失败:" + e.getMessage()); } } /** * SDK 导出会话(含所有权校验,返回文本内容) * 通过 accountId + roleId 验证会话归属后导出。 * * @param conversationId 会话 ID * @param accountId 外部账户 ID(必传) * @param roleId 角色 ID(必传) * @return 导出的会话文本 */ @GetMapping(value = "/sdk/conversation/{id}/export", produces = MediaType.TEXT_PLAIN_VALUE + ";charset=UTF-8") public String sdkExportConversation( @PathVariable("id") String conversationId, @RequestParam String accountId, @RequestParam Long roleId) { try { conversationService.getConversationMessagesForSdk(conversationId, accountId, roleId); return conversationService.exportConversation(conversationId); } catch (IllegalArgumentException e) { return "导出失败:" + e.getMessage(); } catch (Exception e) { log.error("SDK 会话导出失败: conversationId={}, accountId={}", conversationId, accountId, e); return "导出失败:" + e.getMessage(); } } /** * SDK 截断会话(含所有权校验) * 用于"编辑历史消息并重发"场景,仅允许截断属于自己的会话。 * * @param conversationId 会话 ID * @param body { "userTurn": 第几条用户消息, "accountId": "xxx", "roleId": 1 } * @return 逻辑删除的消息条数 */ @PostMapping("/sdk/conversation/{id}/truncate") public Map sdkTruncateConversation( @PathVariable("id") String conversationId, @RequestBody java.util.Map body) { try { Object rawTurn = body.get("userTurn"); int userTurn = rawTurn instanceof Number n ? n.intValue() : Integer.parseInt(String.valueOf(rawTurn)); String accountId = (String) body.get("accountId"); Long roleId = body.get("roleId") instanceof Number n ? n.longValue() : null; int deleted = conversationService.truncateFromUserTurnForSdk( conversationId, userTurn, accountId, roleId); return Map.of("success", true, "deletedMessages", deleted); } catch (IllegalArgumentException e) { return Map.of("success", false, "message", e.getMessage()); } catch (Exception e) { log.error("SDK 会话截断失败: conversationId={}", conversationId, e); return Map.of("success", false, "message", "截断失败:" + e.getMessage()); } } /** * 严格隔离模式下,命中角色但未绑定任何知识库分类 ⇒ 拒绝检索任何 KB。 * 非严格模式下永远返回 false(未绑定 = 可检索全部,沿用通用角色语义)。 */ private boolean isKbDenied(RoleScope scope) { return roleAccessConfig.isStrictIsolation() && scope.hasRole() && scope.categoryIds().isEmpty(); } /** * 寒暄、感谢、告别这类短消息没有知识库检索意图,直接走普通对话。 * 否则向量库会被迫召回一个“最相似但实际无关”的片段,导致回答下方出现误导性来源。 */ private boolean shouldBypassKnowledgeRetrieval(String message) { if (!StringUtils.hasText(message)) { return true; } String normalized = message.trim() .toLowerCase(Locale.ROOT) .replaceAll("[\\s,。!?!?,.;;::、~~…]+", ""); if (normalized.isEmpty()) { return true; } if (normalized.length() > 12) { return false; } return List.of( "你好", "您好", "hello", "hi", "哈喽", "嗨", "在吗", "早上好", "上午好", "中午好", "下午好", "晚上好", "谢谢", "感谢", "多谢", "好的", "好", "嗯", "嗯嗯", "再见", "拜拜", "辛苦了" ).contains(normalized); } /** 未指定策略时默认 MULTI_QUERY(多路扩展)。 */ private String normalizeStrategy(String rewriteStrategy) { return (rewriteStrategy != null && !rewriteStrategy.isEmpty()) ? rewriteStrategy : "MULTI_QUERY"; } /** * 命中角色时用 roleId 套用角色人设和知识库范围。 * accountId 是 SDK 宿主侧的外部用户标识,后端不再创建或查询本地账号,也不允许它覆盖 roleId。 */ private AccountRoleContext resolveAccountRole(String accountId, Long fallbackRoleId) { String externalAccountId = StringUtils.hasText(accountId) ? accountId.trim() : null; return new AccountRoleContext(externalAccountId, fallbackRoleId); } private void bindConversation(String chatId, AccountRoleContext context) { conversationService.bindConversation(chatId, context.accountId(), context.roleId()); } private String resolveSystemPrompt(RoleScope scope, String fallbackSystemPrompt) { if (scope.hasRole() && StringUtils.hasText(scope.systemPrompt())) { return scope.systemPrompt(); } return fallbackSystemPrompt; } /** * 解析最终的检索分类范围。 * 命中角色时,强制使用角色绑定的分类(忽略客户端传入),防止越权跨域; * 未命中角色时,沿用客户端传入的分类(便于直接调试/无角色调用)。 */ private List resolveCategoryIds(RoleScope scope, Long categoryId, String categoryIds) { if (scope.hasRole()) { return scope.categoryIds(); } List ids = parseCategoryIds(categoryIds); if (ids.isEmpty() && categoryId != null) { return List.of(categoryId); } return ids; } private List parseCategoryIds(String categoryIds) { if (categoryIds == null || categoryIds.isBlank()) { return Collections.emptyList(); } return Arrays.stream(categoryIds.split(",")) .map(String::trim) .filter(item -> !item.isEmpty()) .map(Long::valueOf) .filter(id -> id > 0) .distinct() .toList(); } private record AccountRoleContext(String accountId, Long roleId) { } }