You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
397 lines
19 KiB
397 lines
19 KiB
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<String> 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<String> 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<String, Object> 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<Long> cats = resolveCategoryIds(scope, categoryId, categoryIds);
|
|
ChatContext ctx = new ChatContext(message, chatId, "CHAT", null, null, cats,
|
|
normalizeStrategy(rewriteStrategy), true, false);
|
|
List<Document> docs = assistantApp.retrieveSources(ctx);
|
|
List<Map<String, Object>> out = new ArrayList<>();
|
|
for (Document doc : docs) {
|
|
Map<String, Object> meta = doc.getMetadata();
|
|
Map<String, Object> 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<Long> 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<String, Object> sdkListConversations(
|
|
@RequestParam(defaultValue = "1") int page,
|
|
@RequestParam(defaultValue = "20") int size,
|
|
@RequestParam String accountId,
|
|
@RequestParam Long roleId) {
|
|
try {
|
|
Map<String, Object> result = conversationService.listConversationsForSdk(page, size, accountId, roleId);
|
|
Map<String, Object> 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<String, Object> sdkGetConversationMessages(
|
|
@PathVariable("id") String conversationId,
|
|
@RequestParam String accountId,
|
|
@RequestParam Long roleId) {
|
|
try {
|
|
List<Map<String, Object>> 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<String, Object> 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<String, Object> sdkTruncateConversation(
|
|
@PathVariable("id") String conversationId,
|
|
@RequestBody java.util.Map<String, Object> 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<Long> resolveCategoryIds(RoleScope scope, Long categoryId, String categoryIds) {
|
|
if (scope.hasRole()) {
|
|
return scope.categoryIds();
|
|
}
|
|
List<Long> ids = parseCategoryIds(categoryIds);
|
|
if (ids.isEmpty() && categoryId != null) {
|
|
return List.of(categoryId);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
private List<Long> 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<String> 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<String, Object> 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 动态拉取保密声明等配置)
|
|
* <p>
|
|
* 路径在 /ai/ 下,由 SdkAuthFilter 守卫;无 Token 的 SDK 请求会被拦截返回 401。
|
|
*
|
|
* @param key 配置键,如 "disclaimer"
|
|
* @return { success: true, data: { configKey, configValue } }
|
|
*/
|
|
@GetMapping("/system-config/{key}")
|
|
public ResponseEntity<Map<String, Object>> 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()
|
|
));
|
|
}
|
|
}
|
|
|
|
}
|