package com.wok.supportbot.controller; import com.wok.supportbot.app.AssistantApp; import com.wok.supportbot.app.ChatContext; import com.wok.supportbot.app.SuggestionGenerator; import com.wok.supportbot.cache.SuggestionCache; import com.wok.supportbot.config.RoleAccessConfig; import com.wok.supportbot.entity.ApiKey; import com.wok.supportbot.entity.SearchResult; import com.wok.supportbot.rag.CategoryFilter; import com.wok.supportbot.rag.HybridSearchService; import com.wok.supportbot.rag.SearchMode; import com.wok.supportbot.service.ApiKeyService; import com.wok.supportbot.service.CustomerServiceRoleService; import com.wok.supportbot.service.CustomerServiceRoleService.RoleScope; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; import reactor.core.publisher.Flux; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; /** * 开放 API 接口 * 提供第三方系统调用的对话和检索能力,鉴权由 ApiKeyAuthFilter 处理。 */ @Slf4j @RestController @RequestMapping("/open-api") public class OpenApiController { @Autowired private AssistantApp assistantApp; @Autowired private HybridSearchService hybridSearchService; @Autowired private SuggestionCache suggestionCache; @Autowired private SuggestionGenerator suggestionGenerator; @Autowired private ApiKeyService apiKeyService; @Autowired private CustomerServiceRoleService customerServiceRoleService; @Autowired private CategoryFilter categoryFilter; @Autowired private RoleAccessConfig roleAccessConfig; private static final ObjectMapper objectMapper = new ObjectMapper(); /** * 同步对话接口(已接入 ChatPipeline,补齐角色/RAG/FAQ/MCP/分类隔离能力) */ @PostMapping("/chat") public ResponseEntity> chat( @RequestParam String message, @RequestParam(required = false) String roleId, @RequestParam(required = false) String chatId, @RequestParam(required = false) String categoryIds, @RequestParam(required = false) String rewriteStrategy, @RequestParam(required = false) Boolean enableRag, HttpServletRequest request) { try { ApiKey apiKey = getApiKeyFromRequest(request); String resolvedChatId = (chatId != null && !chatId.isBlank()) ? chatId : "openapi-" + apiKey.getId() + "-" + System.currentTimeMillis(); ChatContext ctx = buildOpenApiChatContext(message, resolvedChatId, apiKey, roleId, categoryIds, rewriteStrategy, enableRag, false); String reply = assistantApp.chat(ctx); Map result = new HashMap<>(); result.put("success", true); result.put("data", Map.of( "reply", reply, "chatId", resolvedChatId )); return ResponseEntity.ok(result); } catch (Exception e) { log.error("开放 API 对话失败", e); return ResponseEntity.status(500).body(Map.of( "success", false, "message", "对话失败:" + e.getMessage() )); } } /** * SSE 流式对话接口(已接入 ChatPipeline,补齐角色/RAG/FAQ/MCP/分类隔离能力) */ @GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux chatStream( @RequestParam String message, @RequestParam(required = false) String roleId, @RequestParam(required = false) String chatId, @RequestParam(required = false) String categoryIds, @RequestParam(required = false) String rewriteStrategy, @RequestParam(required = false) Boolean enableRag, HttpServletRequest request) { ApiKey apiKey = getApiKeyFromRequest(request); String resolvedChatId = (chatId != null && !chatId.isBlank()) ? chatId : "openapi-stream-" + apiKey.getId() + "-" + System.currentTimeMillis(); ChatContext ctx = buildOpenApiChatContext(message, resolvedChatId, apiKey, roleId, categoryIds, rewriteStrategy, enableRag, true); return assistantApp.chatStreamOpenAi(ctx); } /** * 知识库检索接口 * * @param query 查询文本 * @param topK 返回条数(默认 5) * @param searchMode 检索模式:VECTOR / KEYWORD / HYBRID(默认 VECTOR) * @return 检索结果列表 */ @PostMapping("/rag/search") public ResponseEntity> ragSearch( @RequestParam String query, @RequestParam(defaultValue = "5") int topK, @RequestParam(defaultValue = "VECTOR") String searchMode) { try { SearchMode mode; try { mode = SearchMode.valueOf(searchMode.toUpperCase()); } catch (IllegalArgumentException e) { mode = SearchMode.VECTOR; } List results = hybridSearchService.search( query, mode, topK, 0.0, Collections.emptyList()); Map result = new HashMap<>(); result.put("success", true); result.put("data", results); result.put("total", results.size()); return ResponseEntity.ok(result); } catch (Exception e) { log.error("开放 API 检索失败", e); return ResponseEntity.status(500).body(Map.of( "success", false, "message", "检索失败:" + e.getMessage() )); } } /** * 获取 AI 推荐问题列表(Open API 用,ApiKeyAuthFilter 守卫)。 * * @param chatId 会话 ID * @return { success: true, data: { suggestions: [...] } } */ @GetMapping("/suggestions") public ResponseEntity> getSuggestions(@RequestParam String chatId) { try { List suggestions = suggestionGenerator.generate(chatId); return ResponseEntity.ok(Map.of( "success", true, "data", Map.of("suggestions", suggestions) )); } catch (Exception e) { log.error("开放 API 获取建议问题失败: chatId={}", chatId, e); return ResponseEntity.ok(Map.of( "success", true, "data", Map.of("suggestions", List.of()) )); } } /** * 从 request attribute 获取已鉴权的 API Key 信息。 */ private ApiKey getApiKeyFromRequest(HttpServletRequest request) { Object attr = request.getAttribute("apiKey"); if (attr instanceof ApiKey apiKey) { return apiKey; } throw new IllegalStateException("API Key 鉴权信息缺失"); } /** * 构造 Open API 对话的 ChatContext(含角色解析、严格隔离判断、分类过滤)。 */ private ChatContext buildOpenApiChatContext(String message, String chatId, ApiKey apiKey, String roleIdStr, String categoryIds, String rewriteStrategy, Boolean enableRag, boolean streaming) { // 1. 解析 API Key 允许的角色列表 List allowedRoleIds = parseAllowedRoleIds(apiKey.getRoleIds()); Long roleId = resolveRoleId(roleIdStr, allowedRoleIds); // 2. 获取角色范围 RoleScope scope = customerServiceRoleService.getRoleScope(roleId); // 3. 解析系统提示词(角色人设) String systemPrompt = scope.hasRole() && StringUtils.hasText(scope.systemPrompt()) ? scope.systemPrompt() : null; // 4. 解析分类隔离范围(角色有绑定则强制使用,否则使用客户端传入) List catIds = resolveCategoryIds(scope, categoryIds); // 5. 严格隔离:角色无分类则拒绝检索 KB boolean useRag = (enableRag == null || enableRag) // 默认启用 RAG && !(roleAccessConfig.isStrictIsolation() && scope.hasRole() && scope.categoryIds().isEmpty()); // 6. 未指定策略时默认 MULTI_QUERY String strategy = (rewriteStrategy != null && !rewriteStrategy.isBlank()) ? rewriteStrategy : "MULTI_QUERY"; return new ChatContext(message, chatId, "CHAT", systemPrompt, scope.hasRole() ? scope.allowedMcpTools() : null, catIds, strategy, useRag, streaming, roleId, scope.name(), null, apiKey.getId(), null); } /** * 解析 API Key 绑定的角色 ID 列表(JSON 字符串如 "[1,2,3]" → List)。 * null / 空数组表示不限制(返回空列表)。 */ private List parseAllowedRoleIds(String roleIdsJson) { if (roleIdsJson == null || roleIdsJson.isBlank() || "[]".equals(roleIdsJson.trim())) { return Collections.emptyList(); } try { return objectMapper.readValue(roleIdsJson, new TypeReference>() {}); } catch (Exception e) { log.warn("解析 API Key role_ids 失败: {}", roleIdsJson, e); return Collections.emptyList(); } } /** * 从客户端传入的 roleId 参数解析有效角色 ID。 * 若 API Key 绑定了角色列表,则仅允许使用绑定的角色;未绑定时允许任意角色。 * 传入 roleId 不在允许列表内时降级为不指定角色(无角色人设/分类/MCP 权限)。 */ private Long resolveRoleId(String roleIdStr, List allowedRoleIds) { if (roleIdStr == null || roleIdStr.isBlank()) { return null; } Long roleId; try { roleId = Long.valueOf(roleIdStr.trim()); } catch (NumberFormatException e) { return null; } if (allowedRoleIds.isEmpty()) { return roleId; } return allowedRoleIds.contains(roleId) ? roleId : null; } /** * 解析分类隔离范围:角色有绑定时强制使用角色的分类,否则使用客户端传入的 categoryIds。 */ private List resolveCategoryIds(RoleScope scope, String categoryIds) { if (scope.hasRole()) { return scope.categoryIds(); } return categoryFilter.parse(categoryIds); } }