package com.wok.supportbot.controller; import com.wok.supportbot.app.AssistantApp; import com.wok.supportbot.app.ChatContext; import com.wok.supportbot.app.ChatPipeline; import com.wok.supportbot.config.RoleAccessConfig; import com.wok.supportbot.rag.CategoryFilter; 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.ResponseEntity; 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 reactor.core.publisher.Flux; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; 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; @Resource private CategoryFilter categoryFilter; @Resource private ChatPipeline chatPipeline; @Resource private com.wok.supportbot.service.SystemConfigService systemConfigService; /** * 同步调用 AI 智能客服应用(已委托 ChatPipeline 新管道,后续版本移除)。 * @deprecated 请使用 {@link AssistantApp#chat(ChatContext)} */ @GetMapping("/assistant_app/chat/sync") @Deprecated public String doChatWithAssistantAppSync(String message, String chatId, Long roleId, String accountId, String systemPrompt) { ChatContext ctx = buildChatContext(message, chatId, roleId, accountId, systemPrompt); return assistantApp.chat(ctx); } /** * SSE 流式调用 AI 智能客服应用(已委托 ChatPipeline 新管道,后续版本移除)。 * @deprecated 请使用 {@link AssistantApp#chatStream(ChatContext)} */ @GetMapping(value = "/assistant_app/chat/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @Deprecated public Flux doChatWithLoveAppSSE(String message, String chatId, Long roleId, String accountId, String systemPrompt) { ChatContext ctx = buildChatContext(message, chatId, roleId, accountId, systemPrompt); return assistantApp.chatStream(ctx); } /** * RAG 知识库同步对话(已委托 ChatPipeline 新管道,后续版本移除)。 * @deprecated 请使用 {@link AssistantApp#chat(ChatContext)} */ @GetMapping("/assistant_app/chat/rag/sync") @Deprecated public String doChatWithRagSync(String message, String chatId, String rewriteStrategy, Long roleId, String accountId, Long categoryId, String categoryIds, String systemPrompt) { ChatContext ctx = buildRagChatContext(message, chatId, rewriteStrategy, roleId, accountId, categoryId, categoryIds, systemPrompt); return assistantApp.chat(ctx); } /** * RAG 知识库流式对话(已委托 ChatPipeline 新管道,后续版本移除)。 * @deprecated 请使用 {@link AssistantApp#chatStream(ChatContext)} */ @GetMapping(value = "/assistant_app/chat/rag/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @Deprecated public Flux doChatWithRagSSE(String message, String chatId, String rewriteStrategy, Long roleId, String accountId, Long categoryId, String categoryIds, String systemPrompt) { ChatContext ctx = buildRagChatContext(message, chatId, rewriteStrategy, roleId, accountId, categoryId, categoryIds, systemPrompt); return assistantApp.chatStream(ctx); } /** * RAG 引用来源(已委托 ChatPipeline 新管道,后续版本移除)。 * @deprecated 请使用 {@link AssistantApp#retrieveSources(ChatContext)} */ @GetMapping("/assistant_app/rag/sources") @Deprecated 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) || chatPipeline.isChitchat(message)) { return Map.of("success", true, "data", List.of()); } try { List cats = resolveCategoryIds(scope, categoryId, categoryIds); ChatContext ctx = new ChatContext(message, chatId, "CHAT", null, null, cats, normalizeStrategy(rewriteStrategy), true, false); List docs = assistantApp.retrieveSources(ctx); 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()); } } // ==================== ChatContext 构建辅助 ==================== /** 构造普通对话的 ChatContext(enableRag=false)。 */ private ChatContext buildChatContext(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 new ChatContext(message, chatId, "CHAT", resolveSystemPrompt(scope, systemPrompt), scope.allowedMcpTools(), null, null, false, false); } /** 构造 RAG 对话的 ChatContext(含严格隔离判断:KbDenied 则 enableRag=false)。 */ private ChatContext buildRagChatContext(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); boolean enableRag = !isKbDenied(scope); List cats = resolveCategoryIds(scope, categoryId, categoryIds); return new ChatContext(message, chatId, "CHAT", sys, scope.allowedMcpTools(), cats, normalizeStrategy(rewriteStrategy), enableRag, false); } // ==================== SDK 会话管理接口(带账户归属校验) ==================== @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()); } } @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()); } } @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()); } } @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(); } } @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。 */ private boolean isKbDenied(RoleScope scope) { return roleAccessConfig.isStrictIsolation() && scope.hasRole() && scope.categoryIds().isEmpty(); } /** 未指定策略时默认 MULTI_QUERY(多路扩展)。 */ private String normalizeStrategy(String rewriteStrategy) { return (rewriteStrategy != null && !rewriteStrategy.isEmpty()) ? rewriteStrategy : "MULTI_QUERY"; } 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) { return categoryFilter.parse(categoryIds); } private record AccountRoleContext(String accountId, Long roleId) { } // ==================== 标准端点(新) ==================== /** * 同步对话(标准路径)。 * 根据 enableRag 参数自动选择普通对话或 RAG 增强对话。 */ @GetMapping("/chat") public String chatSync( @RequestParam String message, @RequestParam(required = false) String chatId, @RequestParam(required = false) Long roleId, @RequestParam(required = false) String accountId, @RequestParam(required = false) String systemPrompt, @RequestParam(required = false) Boolean enableRag, @RequestParam(required = false) String rewriteStrategy, @RequestParam(required = false) Long categoryId, @RequestParam(required = false) String categoryIds) { ChatContext ctx; if (Boolean.TRUE.equals(enableRag)) { ctx = buildRagChatContext(message, chatId, rewriteStrategy, roleId, accountId, categoryId, categoryIds, systemPrompt); } else { ctx = buildChatContext(message, chatId, roleId, accountId, systemPrompt); } return assistantApp.chat(ctx); } /** * SSE 流式对话(标准路径)。 * 统一 SSE 入口,通过 enableRag 控制是否启用知识库增强。 */ @GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux chatStream( @RequestParam String message, @RequestParam(required = false) String chatId, @RequestParam(required = false) Long roleId, @RequestParam(required = false) String accountId, @RequestParam(required = false) String systemPrompt, @RequestParam(required = false) Boolean enableRag, @RequestParam(required = false) String rewriteStrategy, @RequestParam(required = false) Long categoryId, @RequestParam(required = false) String categoryIds) { ChatContext ctx; if (Boolean.TRUE.equals(enableRag)) { ctx = buildRagChatContext(message, chatId, rewriteStrategy, roleId, accountId, categoryId, categoryIds, systemPrompt); } else { ctx = buildChatContext(message, chatId, roleId, accountId, systemPrompt); } ctx = new ChatContext(ctx.message(), ctx.chatId(), ctx.appType(), ctx.systemPrompt(), ctx.allowedMcpTools(), ctx.categoryIds(), ctx.rewriteStrategy(), ctx.enableRag(), true); return assistantApp.chatStream(ctx); } /** * RAG 引用来源(标准路径)。 */ @GetMapping("/chat/sources") public Map chatSources( @RequestParam String message, @RequestParam(required = false) String chatId, @RequestParam(required = false) String rewriteStrategy, @RequestParam(required = false) Long roleId, @RequestParam(required = false) String accountId, @RequestParam(required = false) Long categoryId, @RequestParam(required = false) String categoryIds) { return getRagSources(message, chatId, rewriteStrategy, roleId, accountId, categoryId, categoryIds); } // ==================== SDK 公开接口 ==================== /** * 获取系统配置值(供 SDK 动态拉取保密声明等配置) *

* 路径在 /ai/ 下,由 SdkAuthFilter 守卫;无 Token 的 SDK 请求会被拦截返回 401。 * * @param key 配置键,如 "disclaimer" * @return { success: true, data: { configKey, configValue } } */ @GetMapping("/system-config/{key}") public ResponseEntity> getSdkSystemConfig(@PathVariable String key) { try { String value = systemConfigService.getValueByKey(key); return ResponseEntity.ok(Map.of( "success", true, "data", Map.of("configKey", key, "configValue", value != null ? value : "") )); } catch (Exception e) { log.error("SDK 获取系统配置失败, key={}", key, e); return ResponseEntity.status(500).body(Map.of( "success", false, "message", "获取配置失败:" + e.getMessage() )); } } }