diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotController.java index 45707e9..e69a656 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotController.java @@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; +import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext; import tech.easyflow.ai.easyagents.listener.PromptChoreChatStreamListener; import tech.easyflow.ai.entity.*; import tech.easyflow.ai.publish.BotPublishAppService; @@ -407,23 +408,32 @@ public class BotController extends BaseCurdController { } private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List attachments) { - LoginAccount account = SaTokenUtil.getLoginAccount(); + LoginAccount account = requireCurrentLoginAccount(); ChatRuntimeContext context = new ChatRuntimeContext(); context.setChannel(ChatChannel.ADMIN); context.setSessionId(conversationId); - context.setTenantId(account == null ? BigInteger.ZERO : account.getTenantId()); - context.setDeptId(account == null ? BigInteger.ZERO : account.getDeptId()); - context.setUserId(account == null ? BigInteger.ZERO : account.getId()); - context.setUserAccount(account == null ? "admin" : account.getLoginName()); - context.setUserName(account == null ? "管理员" : (StringUtils.hasText(account.getNickname()) ? account.getNickname() : account.getLoginName())); + context.setTenantId(account.getTenantId()); + context.setDeptId(account.getDeptId()); + context.setUserId(account.getId()); + context.setUserAccount(account.getLoginName()); + context.setUserName(StringUtils.hasText(account.getNickname()) ? account.getNickname() : account.getLoginName()); context.setAssistantId(bot == null ? BigInteger.ZERO : bot.getId()); context.setAssistantCode(bot == null ? null : bot.getAlias()); context.setAssistantName(bot == null ? null : bot.getTitle()); context.setSessionTitle(prompt.length() > 200 ? prompt.substring(0, 200) : prompt); context.setAttachments(attachments); + ChatTimeToolAvailabilityContext.bindLoggedInSnapshot(context, account, bot); return context; } + private LoginAccount requireCurrentLoginAccount() { + try { + return SaTokenUtil.getLoginAccount(); + } catch (Exception e) { + throw new BusinessException("当前登录状态失效,请重新登录后再试"); + } + } + @Override protected Result onRemoveBefore(Collection ids) { QueryWrapper queryWrapperKnowledge = QueryWrapper.create().in(BotDocumentCollection::getBotId, ids); diff --git a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotController.java b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotController.java index fbefe6e..956abea 100644 --- a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotController.java +++ b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotController.java @@ -17,6 +17,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext; import tech.easyflow.ai.entity.*; import tech.easyflow.ai.service.*; import tech.easyflow.ai.service.impl.BotServiceImpl; @@ -287,24 +288,33 @@ public class UcBotController extends BaseCurdController { } private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List attachments) { - LoginAccount account = SaTokenUtil.getLoginAccount(); + LoginAccount account = requireCurrentLoginAccount(); ChatRuntimeContext context = new ChatRuntimeContext(); context.setChannel(ChatChannel.USER_CENTER); context.setSessionId(conversationId); - context.setTenantId(account == null ? BigInteger.ZERO : account.getTenantId()); - context.setDeptId(account == null ? BigInteger.ZERO : account.getDeptId()); - context.setUserId(account == null ? BigInteger.ZERO : account.getId()); - context.setUserAccount(account == null ? "anonymous" : account.getLoginName()); - context.setUserName(account == null ? "匿名用户" : (StringUtils.hasText(account.getNickname()) ? account.getNickname() : account.getLoginName())); + context.setTenantId(account.getTenantId()); + context.setDeptId(account.getDeptId()); + context.setUserId(account.getId()); + context.setUserAccount(account.getLoginName()); + context.setUserName(StringUtils.hasText(account.getNickname()) ? account.getNickname() : account.getLoginName()); context.setAssistantId(bot == null ? BigInteger.ZERO : bot.getId()); context.setAssistantCode(bot == null ? null : bot.getAlias()); context.setAssistantName(bot == null ? null : bot.getTitle()); context.setSessionTitle(prompt.length() > 200 ? prompt.substring(0, 200) : prompt); - context.setAnonymous(account == null || BigInteger.ZERO.equals(account.getId())); + context.setAnonymous(false); context.setAttachments(attachments); + ChatTimeToolAvailabilityContext.bindLoggedInSnapshot(context, account, bot); return context; } + private LoginAccount requireCurrentLoginAccount() { + try { + return SaTokenUtil.getLoginAccount(); + } catch (Exception e) { + throw new BusinessException("当前登录状态失效,请重新登录后再试"); + } + } + private Map getDefaultLlmOptions() { Map defaultLlmOptions = new HashMap<>(); defaultLlmOptions.put("temperature", 0.7); diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeKnowledgeAvailabilityResolver.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeKnowledgeAvailabilityResolver.java new file mode 100644 index 0000000..a37381d --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeKnowledgeAvailabilityResolver.java @@ -0,0 +1,100 @@ +package tech.easyflow.ai.chattime.availability; + +import org.springframework.stereotype.Component; +import tech.easyflow.ai.entity.BotDocumentCollection; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.permission.KnowledgeReadAccessSnapshot; +import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.SysDeptService; + +import java.math.BigInteger; +import java.util.Collections; +import java.util.Set; + +/** + * 知识库聊天时可用性判定器。 + */ +@Component +public class ChatTimeKnowledgeAvailabilityResolver implements ChatTimeToolAvailabilityResolver { + + private final KnowledgeVisibilityQueryHelper knowledgeVisibilityQueryHelper; + private final CategoryPermissionService categoryPermissionService; + private final SysDeptService sysDeptService; + + public ChatTimeKnowledgeAvailabilityResolver(KnowledgeVisibilityQueryHelper knowledgeVisibilityQueryHelper, + CategoryPermissionService categoryPermissionService, + SysDeptService sysDeptService) { + this.knowledgeVisibilityQueryHelper = knowledgeVisibilityQueryHelper; + this.categoryPermissionService = categoryPermissionService; + this.sysDeptService = sysDeptService; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean supports(Object candidate) { + return candidate instanceof DocumentCollection || candidate instanceof BotDocumentCollection; + } + + /** + * {@inheritDoc} + */ + @Override + public ChatTimeToolAvailabilityDecision resolve(ChatTimeToolAvailabilityContext context, Object candidate) { + LoginAccount loginAccount = context == null ? null : context.getLoginAccount(); + if (loginAccount == null || loginAccount.getId() == null) { + return ChatTimeToolAvailabilityDecision.unavailable("CHAT_TIME_LOGIN_ACCOUNT_MISSING", "聊天上下文缺少当前用户身份"); + } + DocumentCollection knowledge = extractKnowledge(candidate); + if (knowledge == null) { + return ChatTimeToolAvailabilityDecision.unavailable("CHAT_TIME_KNOWLEDGE_MISSING", "聊天绑定的知识库不存在"); + } + KnowledgeReadAccessSnapshot readSnapshot = buildReadSnapshot(loginAccount); + if (knowledgeVisibilityQueryHelper.canRead(knowledge, readSnapshot)) { + return ChatTimeToolAvailabilityDecision.available(); + } + return ChatTimeToolAvailabilityDecision.unavailable("CHAT_TIME_KNOWLEDGE_FORBIDDEN", "当前用户无权在聊天中访问该知识库"); + } + + /** + * 基于显式登录快照构造知识库读权限快照,避免依赖线程登录态。 + * + * @param loginAccount 当前聊天用户 + * @return 读权限快照 + */ + protected KnowledgeReadAccessSnapshot buildReadSnapshot(LoginAccount loginAccount) { + RoleCategoryAccessSnapshot categoryAccess = categoryPermissionService.getAccess( + CategoryResourceType.KNOWLEDGE.getCode(), + loginAccount + ); + if (categoryAccess.isSuperAdmin()) { + return new KnowledgeReadAccessSnapshot(categoryAccess, Collections.emptySet()); + } + BigInteger deptId = loginAccount.getDeptId(); + Set readableDeptIds = deptId == null + ? Collections.emptySet() + : sysDeptService.getSelfAndAncestorDeptIds(deptId); + return new KnowledgeReadAccessSnapshot(categoryAccess, readableDeptIds); + } + + /** + * 从聊天工具候选项中提取知识库实体。 + * + * @param candidate 候选项 + * @return 知识库实体 + */ + protected DocumentCollection extractKnowledge(Object candidate) { + if (candidate instanceof DocumentCollection documentCollection) { + return documentCollection; + } + if (candidate instanceof BotDocumentCollection botDocumentCollection) { + return botDocumentCollection.getKnowledge(); + } + return null; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityContext.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityContext.java new file mode 100644 index 0000000..e67b09e --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityContext.java @@ -0,0 +1,122 @@ +package tech.easyflow.ai.chattime.availability; + +import tech.easyflow.ai.entity.Bot; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.core.runtime.ChatChannel; +import tech.easyflow.core.runtime.ChatRuntimeContext; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.Map; + +/** + * 聊天时工具可用性判定上下文。 + */ +public class ChatTimeToolAvailabilityContext implements Serializable { + + /** + * 运行时上下文中保存聊天时权限快照的扩展字段 key。 + */ + public static final String RUNTIME_EXT_KEY = "chatTimeToolAvailabilityContext"; + + private LoginAccount loginAccount; + + private Bot bot; + + private ChatChannel chatChannel; + + private BigInteger sessionId; + + /** + * 将当前登录用户快照绑定到运行时上下文。 + * + * @param runtimeContext 聊天运行时上下文 + * @param loginAccount 登录用户快照 + * @param bot 当前聊天助手 + */ + public static void bindLoggedInSnapshot(ChatRuntimeContext runtimeContext, LoginAccount loginAccount, Bot bot) { + if (!hasLoggedInAccount(loginAccount)) { + return; + } + ChatTimeToolAvailabilityContext chatTimeContext = new ChatTimeToolAvailabilityContext(); + chatTimeContext.setLoginAccount(loginAccount); + chatTimeContext.setBot(bot); + chatTimeContext.setChatChannel(runtimeContext == null ? null : runtimeContext.getChannel()); + chatTimeContext.setSessionId(runtimeContext == null ? null : runtimeContext.getSessionId()); + chatTimeContext.bindToRuntimeContext(runtimeContext); + } + + /** + * 判断是否为可用于聊天态权限判定的登录用户。 + * + * @param loginAccount 登录用户快照 + * @return 是否为有效登录用户 + */ + public static boolean hasLoggedInAccount(LoginAccount loginAccount) { + return loginAccount != null + && loginAccount.getId() != null + && !BigInteger.ZERO.equals(loginAccount.getId()); + } + + /** + * 绑定到聊天运行时上下文,供异步链路显式透传。 + * + * @param runtimeContext 聊天运行时上下文 + */ + public void bindToRuntimeContext(ChatRuntimeContext runtimeContext) { + if (runtimeContext == null) { + return; + } + Map ext = runtimeContext.getExt(); + ext.put(RUNTIME_EXT_KEY, this); + } + + /** + * 从聊天运行时上下文中读取聊天时权限上下文。 + * + * @param runtimeContext 聊天运行时上下文 + * @return 聊天时权限上下文,不存在时返回 null + */ + public static ChatTimeToolAvailabilityContext fromRuntimeContext(ChatRuntimeContext runtimeContext) { + if (runtimeContext == null || runtimeContext.getExt() == null) { + return null; + } + Object value = runtimeContext.getExt().get(RUNTIME_EXT_KEY); + if (value instanceof ChatTimeToolAvailabilityContext context) { + return context; + } + return null; + } + + public LoginAccount getLoginAccount() { + return loginAccount; + } + + public void setLoginAccount(LoginAccount loginAccount) { + this.loginAccount = loginAccount; + } + + public Bot getBot() { + return bot; + } + + public void setBot(Bot bot) { + this.bot = bot; + } + + public ChatChannel getChatChannel() { + return chatChannel; + } + + public void setChatChannel(ChatChannel chatChannel) { + this.chatChannel = chatChannel; + } + + public BigInteger getSessionId() { + return sessionId; + } + + public void setSessionId(BigInteger sessionId) { + this.sessionId = sessionId; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityDecision.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityDecision.java new file mode 100644 index 0000000..9b6b3f5 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityDecision.java @@ -0,0 +1,63 @@ +package tech.easyflow.ai.chattime.availability; + +/** + * 聊天时工具可用性判定结果。 + */ +public class ChatTimeToolAvailabilityDecision { + + private boolean available; + + private String reasonCode; + + private String reasonMessage; + + /** + * 创建可用判定。 + * + * @return 可用判定 + */ + public static ChatTimeToolAvailabilityDecision available() { + ChatTimeToolAvailabilityDecision decision = new ChatTimeToolAvailabilityDecision(); + decision.setAvailable(true); + return decision; + } + + /** + * 创建不可用判定。 + * + * @param reasonCode 原因编码 + * @param reasonMessage 原因说明 + * @return 不可用判定 + */ + public static ChatTimeToolAvailabilityDecision unavailable(String reasonCode, String reasonMessage) { + ChatTimeToolAvailabilityDecision decision = new ChatTimeToolAvailabilityDecision(); + decision.setAvailable(false); + decision.setReasonCode(reasonCode); + decision.setReasonMessage(reasonMessage); + return decision; + } + + public boolean isAvailable() { + return available; + } + + public void setAvailable(boolean available) { + this.available = available; + } + + public String getReasonCode() { + return reasonCode; + } + + public void setReasonCode(String reasonCode) { + this.reasonCode = reasonCode; + } + + public String getReasonMessage() { + return reasonMessage; + } + + public void setReasonMessage(String reasonMessage) { + this.reasonMessage = reasonMessage; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityResolver.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityResolver.java new file mode 100644 index 0000000..4de2233 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityResolver.java @@ -0,0 +1,24 @@ +package tech.easyflow.ai.chattime.availability; + +/** + * 聊天时工具可用性判定器。 + */ +public interface ChatTimeToolAvailabilityResolver { + + /** + * 当前判定器是否支持指定候选项。 + * + * @param candidate 聊天工具候选项 + * @return 是否支持 + */ + boolean supports(Object candidate); + + /** + * 计算候选项在当前聊天上下文中的可用性。 + * + * @param context 聊天时上下文 + * @param candidate 聊天工具候选项 + * @return 判定结果 + */ + ChatTimeToolAvailabilityDecision resolve(ChatTimeToolAvailabilityContext context, Object candidate); +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityService.java new file mode 100644 index 0000000..cfee233 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityService.java @@ -0,0 +1,37 @@ +package tech.easyflow.ai.chattime.availability; + +import java.util.List; + +/** + * 聊天时工具可用性编排服务。 + */ +public interface ChatTimeToolAvailabilityService { + + /** + * 评估单个候选项的聊天时可用性。 + * + * @param context 聊天时上下文 + * @param candidate 聊天工具候选项 + * @return 判定结果 + */ + ChatTimeToolAvailabilityDecision evaluate(ChatTimeToolAvailabilityContext context, Object candidate); + + /** + * 过滤当前聊天上下文中可用的候选项。 + * + * @param context 聊天时上下文 + * @param candidates 候选项列表 + * @param 候选项类型 + * @return 过滤后的候选项 + */ + List filterAvailable(ChatTimeToolAvailabilityContext context, List candidates); + + /** + * 断言候选项在当前聊天上下文中可用。 + * + * @param context 聊天时上下文 + * @param candidate 聊天工具候选项 + * @param fallbackMessage 默认兜底文案 + */ + void assertAvailable(ChatTimeToolAvailabilityContext context, Object candidate, String fallbackMessage); +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityServiceImpl.java new file mode 100644 index 0000000..8d544f8 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityServiceImpl.java @@ -0,0 +1,68 @@ +package tech.easyflow.ai.chattime.availability; + +import org.springframework.stereotype.Service; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 聊天时工具可用性编排服务实现。 + */ +@Service +public class ChatTimeToolAvailabilityServiceImpl implements ChatTimeToolAvailabilityService { + + private final List resolvers; + + public ChatTimeToolAvailabilityServiceImpl(List resolvers) { + this.resolvers = resolvers == null ? Collections.emptyList() : resolvers; + } + + /** + * {@inheritDoc} + */ + @Override + public ChatTimeToolAvailabilityDecision evaluate(ChatTimeToolAvailabilityContext context, Object candidate) { + if (candidate == null) { + return ChatTimeToolAvailabilityDecision.unavailable("TOOL_CANDIDATE_MISSING", "聊天工具候选项不存在"); + } + for (ChatTimeToolAvailabilityResolver resolver : resolvers) { + if (resolver.supports(candidate)) { + return resolver.resolve(context, candidate); + } + } + return ChatTimeToolAvailabilityDecision.unavailable("TOOL_RESOLVER_MISSING", "当前聊天工具缺少可用性判定器"); + } + + /** + * {@inheritDoc} + */ + @Override + public List filterAvailable(ChatTimeToolAvailabilityContext context, List candidates) { + if (candidates == null || candidates.isEmpty()) { + return Collections.emptyList(); + } + return candidates.stream() + .filter(Objects::nonNull) + .filter(candidate -> evaluate(context, candidate).isAvailable()) + .collect(Collectors.toList()); + } + + /** + * {@inheritDoc} + */ + @Override + public void assertAvailable(ChatTimeToolAvailabilityContext context, Object candidate, String fallbackMessage) { + ChatTimeToolAvailabilityDecision decision = evaluate(context, candidate); + if (decision.isAvailable()) { + return; + } + String message = decision.getReasonMessage(); + if (message == null || message.isBlank()) { + message = fallbackMessage; + } + throw new BusinessException(message == null || message.isBlank() ? "当前聊天工具不可用" : message); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/DocumentCollectionTool.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/DocumentCollectionTool.java index 6651920..2769c76 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/DocumentCollectionTool.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/DocumentCollectionTool.java @@ -4,30 +4,69 @@ import com.easyagents.core.document.Document; import com.easyagents.core.model.chat.tool.BaseTool; import com.easyagents.core.model.chat.tool.Parameter; import com.easyagents.rag.retrieval.RetrievalMode; +import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext; +import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityService; import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.rag.KnowledgeRetrievalRequest; import tech.easyflow.ai.service.DocumentCollectionService; import tech.easyflow.common.util.SpringContextUtil; +import tech.easyflow.common.web.exceptions.BusinessException; import java.math.BigInteger; import java.util.List; import java.util.Map; +/** + * 知识库聊天工具。 + */ public class DocumentCollectionTool extends BaseTool { private BigInteger knowledgeId; private RetrievalMode retrievalMode = RetrievalMode.HYBRID; + private ChatTimeToolAvailabilityContext chatTimeContext; + /** + * 默认构造器。 + */ public DocumentCollectionTool() { } + /** + * 基于知识库实体构造聊天工具。 + * + * @param documentCollection 知识库 + * @param needEnglishName 是否使用英文名 + */ public DocumentCollectionTool(DocumentCollection documentCollection, boolean needEnglishName) { this(documentCollection, needEnglishName, RetrievalMode.HYBRID); } + /** + * 基于知识库实体构造聊天工具。 + * + * @param documentCollection 知识库 + * @param needEnglishName 是否使用英文名 + * @param retrievalMode 检索模式 + */ public DocumentCollectionTool(DocumentCollection documentCollection, boolean needEnglishName, RetrievalMode retrievalMode) { + this(documentCollection, needEnglishName, retrievalMode, null); + } + + /** + * 基于知识库实体和聊天时权限上下文构造聊天工具。 + * + * @param documentCollection 知识库 + * @param needEnglishName 是否使用英文名 + * @param retrievalMode 检索模式 + * @param chatTimeContext 聊天时权限上下文 + */ + public DocumentCollectionTool(DocumentCollection documentCollection, + boolean needEnglishName, + RetrievalMode retrievalMode, + ChatTimeToolAvailabilityContext chatTimeContext) { this.knowledgeId = documentCollection.getId(); this.retrievalMode = retrievalMode == null ? RetrievalMode.HYBRID : retrievalMode; + this.chatTimeContext = chatTimeContext; if (needEnglishName) { this.name = documentCollection.getEnglishName(); } else { @@ -63,10 +102,42 @@ public class DocumentCollectionTool extends BaseTool { this.retrievalMode = retrievalMode == null ? RetrievalMode.HYBRID : retrievalMode; } + /** + * 获取聊天时权限上下文。 + * + * @return 聊天时权限上下文 + */ + public ChatTimeToolAvailabilityContext getChatTimeContext() { + return chatTimeContext; + } + + /** + * 设置聊天时权限上下文。 + * + * @param chatTimeContext 聊天时权限上下文 + */ + public void setChatTimeContext(ChatTimeToolAvailabilityContext chatTimeContext) { + this.chatTimeContext = chatTimeContext; + } + + /** + * 执行知识库检索。 + * + * @param argsMap 工具入参 + * @return 检索结果拼接文本 + */ @Override public Object invoke(Map argsMap) { - DocumentCollectionService knowledgeService = SpringContextUtil.getBean(DocumentCollectionService.class); + DocumentCollection knowledge = null; + if (this.knowledgeId != null) { + knowledge = knowledgeService.getById(this.knowledgeId); + } + if (knowledge == null) { + throw new BusinessException("知识库不存在"); + } + assertChatTimeAvailability(knowledge); + KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest(); request.setKnowledgeId(this.knowledgeId); request.setQuery((String) argsMap.get("input")); @@ -91,5 +162,17 @@ public class DocumentCollectionTool extends BaseTool { return sb.toString(); } + /** + * 当工具由聊天运行时装配时,执行聊天态权限兜底。 + * + * @param knowledge 当前知识库实体 + */ + protected void assertChatTimeAvailability(DocumentCollection knowledge) { + if (chatTimeContext == null) { + return; + } + ChatTimeToolAvailabilityService availabilityService = SpringContextUtil.getBean(ChatTimeToolAvailabilityService.class); + availabilityService.assertAvailable(chatTimeContext, knowledge, "当前用户无权在聊天中访问该知识库"); + } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/DocumentCollection.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/DocumentCollection.java index fb66f7e..a9bdfaa 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/DocumentCollection.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/DocumentCollection.java @@ -8,6 +8,7 @@ import com.easyagents.store.milvus.MilvusVectorStore; import com.easyagents.store.milvus.MilvusVectorStoreConfig; import com.mybatisflex.annotation.Table; import tech.easyflow.ai.config.AiMilvusConfig; +import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext; import tech.easyflow.ai.easyagents.tool.DocumentCollectionTool; import tech.easyflow.ai.entity.base.DocumentCollectionBase; import tech.easyflow.ai.rag.KnowledgeRetrievalModes; @@ -111,7 +112,19 @@ public class DocumentCollection extends DocumentCollectionBase implements Visibi } public Tool toFunction(boolean needEnglishName, String retrievalMode) { - return new DocumentCollectionTool(this, needEnglishName, KnowledgeRetrievalModes.parse(retrievalMode)); + return toFunction(needEnglishName, retrievalMode, null); + } + + /** + * 构造知识库聊天工具。 + * + * @param needEnglishName 是否使用英文名称 + * @param retrievalMode 检索模式 + * @param chatTimeContext 聊天时权限上下文 + * @return 聊天工具 + */ + public Tool toFunction(boolean needEnglishName, String retrievalMode, ChatTimeToolAvailabilityContext chatTimeContext) { + return new DocumentCollectionTool(this, needEnglishName, KnowledgeRetrievalModes.parse(retrievalMode), chatTimeContext); } public Object getOptionsByKey(String key) { diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java index 0793fde..cbf32f8 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java @@ -24,10 +24,13 @@ import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext; +import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityService; import tech.easyflow.ai.easyagents.listener.ChatStreamListener; import tech.easyflow.ai.easyagents.memory.DefaultBotMessageMemory; import tech.easyflow.ai.easyagents.memory.PublicBotMessageMemory; import tech.easyflow.ai.easyagents.memory.RuntimeChatMemory; +import tech.easyflow.ai.easyagents.tool.DocumentCollectionTool; import tech.easyflow.ai.easyagents.tool.WorkflowTool; import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds; import tech.easyflow.ai.entity.*; @@ -39,6 +42,7 @@ import tech.easyflow.ai.utils.CustomBeanUtils; import tech.easyflow.ai.utils.RegexUtils; import tech.easyflow.common.filestorage.FileStorageService; import tech.easyflow.common.filestorage.utils.PathGeneratorUtil; +import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.util.MapUtil; import tech.easyflow.common.util.Maps; @@ -131,6 +135,8 @@ public class BotServiceImpl extends ServiceImpl implements BotSe private CategoryPermissionService categoryPermissionService; @Resource private ChatRuntimeManager chatRuntimeManager; + @Resource + private ChatTimeToolAvailabilityService chatTimeToolAvailabilityService; @Override public Bot getDetail(String id) { @@ -274,6 +280,7 @@ public class BotServiceImpl extends ServiceImpl implements BotSe BotServiceImpl.ChatCheckResult chatCheckResult, List attachments, ChatRuntimeContext runtimeContext) { Map modelOptions = chatCheckResult.getModelOptions(); ChatModel chatModel = chatCheckResult.getChatModel(); + ChatTimeToolAvailabilityContext chatTimeContext = buildChatTimeToolAvailabilityContext(runtimeContext, chatCheckResult.getAiBot()); final MemoryPrompt memoryPrompt = new MemoryPrompt(); String systemPrompt = buildSystemPromptWithFaqImageRule( MapUtil.getString(modelOptions, Bot.KEY_SYSTEM_PROMPT) @@ -293,6 +300,7 @@ public class BotServiceImpl extends ServiceImpl implements BotSe userMessage.addTools(buildFunctionList(Maps.of("botId", botId) .set("needEnglishName", false) .set("bot", chatCheckResult.getAiBot()) + .set("chatTimeContext", chatTimeContext) .set("publishedOnly", chatCheckResult.isPublishedAccess()))); ChatOptions chatOptions = getChatOptions(modelOptions); Boolean enableDeepThinking = MapUtil.getBoolean(modelOptions, Bot.KEY_ENABLE_DEEP_THINKING, false); @@ -463,6 +471,7 @@ public class BotServiceImpl extends ServiceImpl implements BotSe needEnglishName = false; } Bot runtimeBot = (Bot) buildParams.get("bot"); + ChatTimeToolAvailabilityContext chatTimeContext = (ChatTimeToolAvailabilityContext) buildParams.get("chatTimeContext"); boolean usePublishedSnapshot = Boolean.TRUE.equals(buildParams.get("publishedOnly")) && runtimeBot != null && runtimeBot.getPublishedSnapshotJson() != null @@ -471,7 +480,7 @@ public class BotServiceImpl extends ServiceImpl implements BotSe QueryWrapper queryWrapper = QueryWrapper.create(); if (usePublishedSnapshot) { appendPublishedWorkflowTools(functionList, runtimeBot, needEnglishName); - appendPublishedKnowledgeTools(functionList, runtimeBot, needEnglishName); + appendPublishedKnowledgeTools(functionList, runtimeBot, needEnglishName, chatTimeContext); } else { // 工作流 function 集合 queryWrapper.eq(BotWorkflow::getBotId, botId); @@ -489,13 +498,7 @@ public class BotServiceImpl extends ServiceImpl implements BotSe queryWrapper.eq(BotDocumentCollection::getBotId, botId); List botDocumentCollections = botDocumentCollectionService.getMapper() .selectListWithRelationsByQuery(queryWrapper); - if (botDocumentCollections != null && !botDocumentCollections.isEmpty()) { - for (BotDocumentCollection botDocumentCollection : botDocumentCollections) { - Tool function = botDocumentCollection.getKnowledge() - .toFunction(needEnglishName, botDocumentCollection.getRetrievalMode().name()); - functionList.add(function); - } - } + functionList.addAll(buildKnowledgeTools(botDocumentCollections, needEnglishName, chatTimeContext)); } // 插件 function 集合 @@ -534,6 +537,39 @@ public class BotServiceImpl extends ServiceImpl implements BotSe return functionList; } + /** + * 将 Bot 绑定的知识库候选项收敛为当前聊天可用的工具列表。 + * + * @param botDocumentCollections Bot 知识库绑定项 + * @param needEnglishName 是否使用英文名称 + * @param chatTimeContext 聊天时权限上下文 + * @return 知识库工具列表 + */ + List buildKnowledgeTools(List botDocumentCollections, + boolean needEnglishName, + ChatTimeToolAvailabilityContext chatTimeContext) { + List functionList = new ArrayList<>(); + if (botDocumentCollections == null || botDocumentCollections.isEmpty()) { + return functionList; + } + List availableBindings = chatTimeContext == null + ? botDocumentCollections + : chatTimeToolAvailabilityService.filterAvailable(chatTimeContext, botDocumentCollections); + for (BotDocumentCollection botDocumentCollection : availableBindings) { + DocumentCollection knowledge = botDocumentCollection.getKnowledge(); + if (knowledge == null) { + continue; + } + DocumentCollectionTool function = (DocumentCollectionTool) knowledge.toFunction( + needEnglishName, + botDocumentCollection.getRetrievalMode().name(), + chatTimeContext + ); + functionList.add(function); + } + return functionList; + } + @SuppressWarnings("unchecked") private void appendPublishedWorkflowTools(List functionList, Bot runtimeBot, boolean needEnglishName) { Object workflows = runtimeBot.getPublishedSnapshotJson().get("workflowBindings"); @@ -562,7 +598,10 @@ public class BotServiceImpl extends ServiceImpl implements BotSe } @SuppressWarnings("unchecked") - private void appendPublishedKnowledgeTools(List functionList, Bot runtimeBot, boolean needEnglishName) { + private void appendPublishedKnowledgeTools(List functionList, + Bot runtimeBot, + boolean needEnglishName, + ChatTimeToolAvailabilityContext chatTimeContext) { Object knowledges = runtimeBot.getPublishedSnapshotJson().get("knowledgeBindings"); if (!(knowledges instanceof List knowledgeBindings)) { return; @@ -579,14 +618,40 @@ public class BotServiceImpl extends ServiceImpl implements BotSe if (knowledge == null) { continue; } + if (chatTimeContext != null && !chatTimeToolAvailabilityService.evaluate(chatTimeContext, knowledge).isAvailable()) { + continue; + } Object retrievalMode = bindingMap.get("retrievalMode"); functionList.add(knowledge.toFunction( needEnglishName, - retrievalMode == null ? null : String.valueOf(retrievalMode) + retrievalMode == null ? null : String.valueOf(retrievalMode), + chatTimeContext )); } } + /** + * 构造聊天时工具可用性上下文,并显式回填到运行时上下文中供后续异步工具调用复用。 + * + * @param runtimeContext 聊天运行时上下文 + * @param bot 当前聊天助手 + * @return 聊天时工具可用性上下文 + */ + private ChatTimeToolAvailabilityContext buildChatTimeToolAvailabilityContext(ChatRuntimeContext runtimeContext, Bot bot) { + ChatTimeToolAvailabilityContext existing = ChatTimeToolAvailabilityContext.fromRuntimeContext(runtimeContext); + LoginAccount loginAccount = existing == null ? null : existing.getLoginAccount(); + if (!ChatTimeToolAvailabilityContext.hasLoggedInAccount(loginAccount)) { + return null; + } + ChatTimeToolAvailabilityContext context = existing == null ? new ChatTimeToolAvailabilityContext() : existing; + context.setLoginAccount(loginAccount); + context.setBot(bot); + context.setChatChannel(runtimeContext == null ? null : runtimeContext.getChannel()); + context.setSessionId(runtimeContext == null ? null : runtimeContext.getSessionId()); + context.bindToRuntimeContext(runtimeContext); + return context; + } + public String attachmentsToString(List fileList) { StringBuilder messageBuilder = new StringBuilder(); if (fileList != null && !fileList.isEmpty()) { diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/chattime/availability/ChatTimeKnowledgeAvailabilityResolverTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/chattime/availability/ChatTimeKnowledgeAvailabilityResolverTest.java new file mode 100644 index 0000000..1bec2dd --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/chattime/availability/ChatTimeKnowledgeAvailabilityResolverTest.java @@ -0,0 +1,187 @@ +package tech.easyflow.ai.chattime.availability; + +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.SysDeptService; + +import java.lang.reflect.Proxy; +import java.math.BigInteger; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * {@link ChatTimeKnowledgeAvailabilityResolver} 单元测试。 + * + * @author Codex + * @since 2026-05-10 + */ +public class ChatTimeKnowledgeAvailabilityResolverTest { + + /** + * 创建者应始终可访问自己的私有知识库。 + */ + @Test + public void resolveShouldAllowCreatorForPrivateKnowledge() { + LoginAccount loginAccount = buildLoginAccount(11, 3); + ChatTimeKnowledgeAvailabilityResolver resolver = buildResolver( + new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(11), false, false, Collections.emptySet()), + setOf(BigInteger.valueOf(3)) + ); + + ChatTimeToolAvailabilityDecision decision = resolver.resolve( + buildContext(loginAccount), + buildKnowledge(11, 21, null, "PRIVATE") + ); + + Assert.assertTrue(decision.isAvailable()); + } + + /** + * 分类权限不通过时,即使知识库是公开范围也不可访问。 + */ + @Test + public void resolveShouldRejectWhenCategoryPermissionFails() { + LoginAccount loginAccount = buildLoginAccount(12, 3); + ChatTimeKnowledgeAvailabilityResolver resolver = buildResolver( + new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(12), false, false, setOf(BigInteger.valueOf(99))), + setOf(BigInteger.valueOf(3)) + ); + + ChatTimeToolAvailabilityDecision decision = resolver.resolve( + buildContext(loginAccount), + buildKnowledge(11, 21, null, "PUBLIC") + ); + + Assert.assertFalse(decision.isAvailable()); + Assert.assertEquals("CHAT_TIME_KNOWLEDGE_FORBIDDEN", decision.getReasonCode()); + } + + /** + * 同部门或祖先部门命中时,应允许访问部门可见知识库。 + */ + @Test + public void resolveShouldAllowDeptScopedKnowledgeForReadableDept() { + LoginAccount loginAccount = buildLoginAccount(12, 9); + ChatTimeKnowledgeAvailabilityResolver resolver = buildResolver( + new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(12), false, false, setOf(BigInteger.valueOf(21))), + setOf(BigInteger.valueOf(1), BigInteger.valueOf(3), BigInteger.valueOf(9)) + ); + + ChatTimeToolAvailabilityDecision decision = resolver.resolve( + buildContext(loginAccount), + buildKnowledge(11, 21, BigInteger.valueOf(3), "DEPT") + ); + + Assert.assertTrue(decision.isAvailable()); + } + + /** + * 超级管理员始终可访问。 + */ + @Test + public void resolveShouldAllowSuperAdmin() { + LoginAccount loginAccount = buildLoginAccount(99, 1); + ChatTimeKnowledgeAvailabilityResolver resolver = buildResolver( + new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(99), true, true, Collections.emptySet()), + Collections.emptySet() + ); + + ChatTimeToolAvailabilityDecision decision = resolver.resolve( + buildContext(loginAccount), + buildKnowledge(11, 21, null, "PRIVATE") + ); + + Assert.assertTrue(decision.isAvailable()); + } + + private ChatTimeKnowledgeAvailabilityResolver buildResolver(RoleCategoryAccessSnapshot accessSnapshot, + Set deptIds) { + return new ChatTimeKnowledgeAvailabilityResolver( + new KnowledgeVisibilityQueryHelper(), + mockCategoryPermissionService(accessSnapshot), + mockSysDeptService(deptIds) + ); + } + + private ChatTimeToolAvailabilityContext buildContext(LoginAccount loginAccount) { + ChatTimeToolAvailabilityContext context = new ChatTimeToolAvailabilityContext(); + context.setLoginAccount(loginAccount); + return context; + } + + private LoginAccount buildLoginAccount(long accountId, long deptId) { + LoginAccount loginAccount = new LoginAccount(); + loginAccount.setId(BigInteger.valueOf(accountId)); + loginAccount.setDeptId(BigInteger.valueOf(deptId)); + return loginAccount; + } + + private DocumentCollection buildKnowledge(long createdBy, long categoryId, BigInteger deptId, String visibilityScope) { + DocumentCollection knowledge = new DocumentCollection(); + knowledge.setId(BigInteger.valueOf(101)); + knowledge.setCreatedBy(BigInteger.valueOf(createdBy)); + knowledge.setCategoryId(BigInteger.valueOf(categoryId)); + knowledge.setDeptId(deptId); + knowledge.setVisibilityScope(visibilityScope); + return knowledge; + } + + private CategoryPermissionService mockCategoryPermissionService(RoleCategoryAccessSnapshot accessSnapshot) { + return (CategoryPermissionService) Proxy.newProxyInstance( + CategoryPermissionService.class.getClassLoader(), + new Class[]{CategoryPermissionService.class}, + (proxy, method, args) -> { + if ("getAccess".equals(method.getName())) { + return accessSnapshot; + } + if ("isSuperAdmin".equals(method.getName())) { + return accessSnapshot.isSuperAdmin(); + } + return defaultValue(method.getReturnType()); + } + ); + } + + private SysDeptService mockSysDeptService(Set deptIds) { + Set readableDeptIds = deptIds == null ? Collections.emptySet() : deptIds; + return (SysDeptService) Proxy.newProxyInstance( + SysDeptService.class.getClassLoader(), + new Class[]{SysDeptService.class}, + (proxy, method, args) -> { + if ("getSelfAndAncestorDeptIds".equals(method.getName())) { + return readableDeptIds; + } + if ("canUserAccessDeptScopedResource".equals(method.getName())) { + BigInteger resourceDeptId = (BigInteger) args[1]; + return readableDeptIds.contains(resourceDeptId); + } + return defaultValue(method.getReturnType()); + } + ); + } + + private Set setOf(BigInteger... values) { + Set result = new LinkedHashSet<>(); + Collections.addAll(result, values); + return result; + } + + private Object defaultValue(Class returnType) { + if (returnType == boolean.class) { + return false; + } + if (returnType == int.class) { + return 0; + } + if (returnType == long.class) { + return 0L; + } + return null; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityContextTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityContextTest.java new file mode 100644 index 0000000..c0432f6 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/chattime/availability/ChatTimeToolAvailabilityContextTest.java @@ -0,0 +1,58 @@ +package tech.easyflow.ai.chattime.availability; + +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.ai.entity.Bot; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.core.runtime.ChatChannel; +import tech.easyflow.core.runtime.ChatRuntimeContext; + +import java.math.BigInteger; + +/** + * {@link ChatTimeToolAvailabilityContext} 单元测试。 + * + * @author Codex + * @since 2026-05-10 + */ +public class ChatTimeToolAvailabilityContextTest { + + /** + * 登录用户快照应被显式绑定到聊天运行时上下文。 + */ + @Test + public void bindLoggedInSnapshotShouldAttachContextToRuntimeExt() { + ChatRuntimeContext runtimeContext = new ChatRuntimeContext(); + runtimeContext.setChannel(ChatChannel.ADMIN); + runtimeContext.setSessionId(BigInteger.valueOf(2001)); + LoginAccount loginAccount = new LoginAccount(); + loginAccount.setId(BigInteger.valueOf(12)); + Bot bot = new Bot(); + bot.setId(BigInteger.valueOf(99)); + + ChatTimeToolAvailabilityContext.bindLoggedInSnapshot(runtimeContext, loginAccount, bot); + + ChatTimeToolAvailabilityContext chatTimeContext = ChatTimeToolAvailabilityContext.fromRuntimeContext(runtimeContext); + Assert.assertNotNull(chatTimeContext); + Assert.assertEquals(BigInteger.valueOf(12), chatTimeContext.getLoginAccount().getId()); + Assert.assertEquals(BigInteger.valueOf(99), chatTimeContext.getBot().getId()); + Assert.assertEquals(ChatChannel.ADMIN, chatTimeContext.getChatChannel()); + Assert.assertEquals(BigInteger.valueOf(2001), chatTimeContext.getSessionId()); + } + + /** + * 匿名或缺失账号快照时不应绑定聊天态权限上下文。 + */ + @Test + public void bindLoggedInSnapshotShouldIgnoreAnonymousAccount() { + ChatRuntimeContext runtimeContext = new ChatRuntimeContext(); + runtimeContext.setChannel(ChatChannel.USER_CENTER); + runtimeContext.setSessionId(BigInteger.valueOf(3001)); + LoginAccount loginAccount = new LoginAccount(); + loginAccount.setId(BigInteger.ZERO); + + ChatTimeToolAvailabilityContext.bindLoggedInSnapshot(runtimeContext, loginAccount, new Bot()); + + Assert.assertNull(ChatTimeToolAvailabilityContext.fromRuntimeContext(runtimeContext)); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagents/tool/DocumentCollectionToolTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagents/tool/DocumentCollectionToolTest.java new file mode 100644 index 0000000..c2f95e1 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagents/tool/DocumentCollectionToolTest.java @@ -0,0 +1,305 @@ +package tech.easyflow.ai.easyagents.tool; + +import com.easyagents.core.document.Document; +import com.easyagents.rag.retrieval.RetrievalMode; +import org.junit.Assert; +import org.junit.Test; +import org.springframework.context.ApplicationContext; +import tech.easyflow.ai.chattime.availability.ChatTimeKnowledgeAvailabilityResolver; +import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext; +import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityService; +import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityServiceImpl; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper; +import tech.easyflow.ai.rag.KnowledgeRetrievalRequest; +import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.util.SpringContextUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.SysDeptService; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.math.BigInteger; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.FutureTask; + +/** + * {@link DocumentCollectionTool} 单元测试。 + * + * @author Codex + * @since 2026-05-10 + */ +public class DocumentCollectionToolTest { + + /** + * 直接构造一个本不应暴露的知识库 Tool 调用时,必须抛出无权限异常。 + * + * @throws Exception 反射注入异常 + */ + @Test + public void invokeShouldThrowWhenChatTimeKnowledgeUnavailable() throws Exception { + TestDocumentCollectionService documentCollectionService = new TestDocumentCollectionService( + buildKnowledge(101, 11, 21, null, "PUBLIC"), + List.of(buildSearchDocument("should-not-reach")) + ); + ChatTimeToolAvailabilityService availabilityService = buildAvailabilityService( + new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(12), false, false, setOf(BigInteger.valueOf(99))), + Collections.emptySet() + ); + ApplicationContext previousContext = getStaticField("applicationContext"); + Object previousBeanFactory = getStaticField("beanFactory"); + try { + setStaticField("beanFactory", null); + setStaticField("applicationContext", mockApplicationContext(documentCollectionService.toProxy(), availabilityService)); + DocumentCollectionTool tool = new DocumentCollectionTool( + buildKnowledge(101, 11, 21, null, "PUBLIC"), + false, + RetrievalMode.HYBRID, + buildContext(12, 3) + ); + + BusinessException exception = Assert.assertThrows( + BusinessException.class, + () -> tool.invoke(Map.of("input", "test")) + ); + + Assert.assertEquals("当前用户无权在聊天中访问该知识库", exception.getMessage()); + Assert.assertEquals(0, documentCollectionService.searchCount); + } finally { + setStaticField("applicationContext", previousContext); + setStaticField("beanFactory", previousBeanFactory); + } + } + + /** + * 异步线程中即使没有当前线程登录态,也应能基于显式快照完成判权并执行检索。 + * + * @throws Exception 反射注入异常 + */ + @Test + public void invokeShouldUseExplicitLoginSnapshotWithoutThreadState() throws Exception { + TestDocumentCollectionService documentCollectionService = new TestDocumentCollectionService( + buildKnowledge(101, 11, 21, null, "PUBLIC"), + List.of(buildSearchDocument("知识片段A"), buildSearchDocument("知识片段B")) + ); + ChatTimeToolAvailabilityService availabilityService = buildAvailabilityService( + new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(12), false, false, setOf(BigInteger.valueOf(21))), + Collections.emptySet() + ); + ApplicationContext previousContext = getStaticField("applicationContext"); + Object previousBeanFactory = getStaticField("beanFactory"); + try { + setStaticField("beanFactory", null); + setStaticField("applicationContext", mockApplicationContext(documentCollectionService.toProxy(), availabilityService)); + DocumentCollectionTool tool = new DocumentCollectionTool( + buildKnowledge(101, 11, 21, null, "PUBLIC"), + false, + RetrievalMode.KEYWORD, + buildContext(12, 3) + ); + FutureTask task = new FutureTask<>(() -> tool.invoke(Map.of("input", "异步查询"))); + Thread thread = new Thread(task, "document-collection-tool-test"); + thread.start(); + + Object result = task.get(); + + Assert.assertEquals("知识片段A\n\n---\n\n知识片段B", result); + Assert.assertEquals(1, documentCollectionService.searchCount); + Assert.assertNotNull(documentCollectionService.lastRequest); + Assert.assertEquals("异步查询", documentCollectionService.lastRequest.getQuery()); + Assert.assertEquals(RetrievalMode.KEYWORD, documentCollectionService.lastRequest.getRetrievalMode()); + Assert.assertEquals("BOT_TOOL", documentCollectionService.lastRequest.getCallerType()); + Assert.assertEquals("101", documentCollectionService.lastRequest.getCallerId()); + } finally { + setStaticField("applicationContext", previousContext); + setStaticField("beanFactory", previousBeanFactory); + } + } + + private ChatTimeToolAvailabilityService buildAvailabilityService(RoleCategoryAccessSnapshot accessSnapshot, + Set deptIds) { + return new ChatTimeToolAvailabilityServiceImpl(List.of( + new ChatTimeKnowledgeAvailabilityResolver( + new KnowledgeVisibilityQueryHelper(), + mockCategoryPermissionService(accessSnapshot), + mockSysDeptService(deptIds) + ) + )); + } + + private ChatTimeToolAvailabilityContext buildContext(long accountId, long deptId) { + LoginAccount loginAccount = new LoginAccount(); + loginAccount.setId(BigInteger.valueOf(accountId)); + loginAccount.setDeptId(BigInteger.valueOf(deptId)); + ChatTimeToolAvailabilityContext context = new ChatTimeToolAvailabilityContext(); + context.setLoginAccount(loginAccount); + return context; + } + + private DocumentCollection buildKnowledge(long knowledgeId, + long createdBy, + long categoryId, + BigInteger deptId, + String visibilityScope) { + DocumentCollection knowledge = new DocumentCollection(); + knowledge.setId(BigInteger.valueOf(knowledgeId)); + knowledge.setTitle("knowledge-" + knowledgeId); + knowledge.setDescription("desc-" + knowledgeId); + knowledge.setCreatedBy(BigInteger.valueOf(createdBy)); + knowledge.setCategoryId(BigInteger.valueOf(categoryId)); + knowledge.setDeptId(deptId); + knowledge.setVisibilityScope(visibilityScope); + return knowledge; + } + + private Document buildSearchDocument(String content) { + Document document = new Document(); + document.setContent(content); + return document; + } + + private ApplicationContext mockApplicationContext(DocumentCollectionService documentCollectionService, + ChatTimeToolAvailabilityService availabilityService) { + return (ApplicationContext) Proxy.newProxyInstance( + ApplicationContext.class.getClassLoader(), + new Class[]{ApplicationContext.class}, + (proxy, method, args) -> { + if ("getBean".equals(method.getName()) && args != null && args.length == 1 && args[0] instanceof Class clazz) { + if (clazz == DocumentCollectionService.class) { + return documentCollectionService; + } + if (clazz == ChatTimeToolAvailabilityService.class) { + return availabilityService; + } + } + if ("equals".equals(method.getName())) { + return proxy == args[0]; + } + if ("hashCode".equals(method.getName())) { + return System.identityHashCode(proxy); + } + return defaultValue(method.getReturnType()); + } + ); + } + + private CategoryPermissionService mockCategoryPermissionService(RoleCategoryAccessSnapshot accessSnapshot) { + return (CategoryPermissionService) Proxy.newProxyInstance( + CategoryPermissionService.class.getClassLoader(), + new Class[]{CategoryPermissionService.class}, + (proxy, method, args) -> { + if ("getAccess".equals(method.getName())) { + return accessSnapshot; + } + if ("isSuperAdmin".equals(method.getName())) { + return accessSnapshot.isSuperAdmin(); + } + return defaultValue(method.getReturnType()); + } + ); + } + + private SysDeptService mockSysDeptService(Set deptIds) { + Set readableDeptIds = deptIds == null ? Collections.emptySet() : deptIds; + return (SysDeptService) Proxy.newProxyInstance( + SysDeptService.class.getClassLoader(), + new Class[]{SysDeptService.class}, + (proxy, method, args) -> { + if ("getSelfAndAncestorDeptIds".equals(method.getName())) { + return readableDeptIds; + } + if ("canUserAccessDeptScopedResource".equals(method.getName())) { + BigInteger resourceDeptId = (BigInteger) args[1]; + return readableDeptIds.contains(resourceDeptId); + } + return defaultValue(method.getReturnType()); + } + ); + } + + @SuppressWarnings("unchecked") + private T getStaticField(String fieldName) throws Exception { + Field field = SpringContextUtil.class.getDeclaredField(fieldName); + field.setAccessible(true); + return (T) field.get(null); + } + + private void setStaticField(String fieldName, Object value) throws Exception { + Field field = SpringContextUtil.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(null, value); + } + + private Set setOf(BigInteger... values) { + Set result = new LinkedHashSet<>(); + Collections.addAll(result, values); + return result; + } + + private Object defaultValue(Class returnType) { + if (returnType == boolean.class) { + return false; + } + if (returnType == int.class) { + return 0; + } + if (returnType == long.class) { + return 0L; + } + return null; + } + + /** + * 记录检索调用的最小知识库服务桩。 + */ + private static class TestDocumentCollectionService { + + private final DocumentCollection knowledge; + private final List searchResult; + private int searchCount; + private KnowledgeRetrievalRequest lastRequest; + + private TestDocumentCollectionService(DocumentCollection knowledge, List searchResult) { + this.knowledge = knowledge; + this.searchResult = searchResult; + } + + private DocumentCollectionService toProxy() { + return (DocumentCollectionService) Proxy.newProxyInstance( + DocumentCollectionService.class.getClassLoader(), + new Class[]{DocumentCollectionService.class}, + (proxy, method, args) -> { + if ("getById".equals(method.getName())) { + return knowledge; + } + if ("search".equals(method.getName()) && args != null && args.length == 1 && args[0] instanceof KnowledgeRetrievalRequest request) { + this.searchCount++; + this.lastRequest = request; + return searchResult; + } + return defaultStaticValue(method.getReturnType()); + } + ); + } + + private static Object defaultStaticValue(Class returnType) { + if (returnType == boolean.class) { + return false; + } + if (returnType == int.class) { + return 0; + } + if (returnType == long.class) { + return 0L; + } + return null; + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/BotServiceImplTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/BotServiceImplTest.java new file mode 100644 index 0000000..0a2d7dc --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/BotServiceImplTest.java @@ -0,0 +1,271 @@ +package tech.easyflow.ai.service.impl; + +import com.easyagents.core.model.chat.tool.Tool; +import com.easyagents.rag.retrieval.RetrievalMode; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.ai.chattime.availability.ChatTimeKnowledgeAvailabilityResolver; +import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext; +import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityService; +import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityServiceImpl; +import tech.easyflow.ai.easyagents.tool.DocumentCollectionTool; +import tech.easyflow.ai.entity.Bot; +import tech.easyflow.ai.entity.BotDocumentCollection; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.SysDeptService; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * {@link BotServiceImpl} 单元测试。 + * + * @author Codex + * @since 2026-05-10 + */ +public class BotServiceImplTest { + + /** + * 仅应为当前用户可访问的知识库生成聊天工具,并保留绑定检索模式。 + * + * @throws Exception 反射注入异常 + */ + @Test + public void buildKnowledgeToolsShouldOnlyCreateAvailableTools() throws Exception { + BotServiceImpl service = new BotServiceImpl(); + injectAvailabilityService(service, buildAvailabilityService( + new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(12), false, false, setOf(BigInteger.valueOf(21))), + Collections.emptySet() + )); + + List tools = service.buildKnowledgeTools( + List.of( + buildBinding(101, 11, 21, "PUBLIC", RetrievalMode.KEYWORD), + buildBinding(102, 11, 99, "PUBLIC", RetrievalMode.HYBRID) + ), + false, + buildContext(12, 3) + ); + + Assert.assertEquals(1, tools.size()); + Assert.assertTrue(tools.get(0) instanceof DocumentCollectionTool); + DocumentCollectionTool tool = (DocumentCollectionTool) tools.get(0); + Assert.assertEquals(BigInteger.valueOf(101), tool.getKnowledgeId()); + Assert.assertEquals(RetrievalMode.KEYWORD, tool.getRetrievalMode()); + Assert.assertNotNull(tool.getChatTimeContext()); + } + + /** + * 当全部绑定知识库都不可用时,聊天工具列表应为空。 + * + * @throws Exception 反射注入异常 + */ + @Test + public void buildKnowledgeToolsShouldReturnEmptyWhenAllBindingsUnavailable() throws Exception { + BotServiceImpl service = new BotServiceImpl(); + injectAvailabilityService(service, buildAvailabilityService( + new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(12), false, false, setOf(BigInteger.valueOf(99))), + Collections.emptySet() + )); + + List tools = service.buildKnowledgeTools( + List.of(buildBinding(101, 11, 21, "PUBLIC", RetrievalMode.HYBRID)), + false, + buildContext(12, 3) + ); + + Assert.assertTrue(tools.isEmpty()); + } + + /** + * 已发布快照分支也应按聊天态权限过滤知识库,并保留检索模式。 + * + * @throws Exception 反射调用异常 + */ + @Test + @SuppressWarnings("unchecked") + public void appendPublishedKnowledgeToolsShouldFilterUnavailableBindings() throws Exception { + BotServiceImpl service = new BotServiceImpl(); + injectAvailabilityService(service, buildAvailabilityService( + new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(12), false, false, setOf(BigInteger.valueOf(21))), + Collections.emptySet() + )); + injectDocumentCollectionService(service, mockDocumentCollectionService( + buildKnowledge(101, 11, 21, "PUBLIC"), + buildKnowledge(102, 11, 99, "PUBLIC") + )); + + Bot runtimeBot = new Bot(); + Map snapshot = new HashMap<>(); + snapshot.put("knowledgeBindings", List.of( + buildPublishedBinding(101, RetrievalMode.KEYWORD), + buildPublishedBinding(102, RetrievalMode.HYBRID) + )); + runtimeBot.setPublishedSnapshotJson(snapshot); + List functionList = new ArrayList<>(); + Method method = BotServiceImpl.class.getDeclaredMethod( + "appendPublishedKnowledgeTools", + List.class, + Bot.class, + boolean.class, + ChatTimeToolAvailabilityContext.class + ); + method.setAccessible(true); + + method.invoke(service, functionList, runtimeBot, false, buildContext(12, 3)); + + Assert.assertEquals(1, functionList.size()); + DocumentCollectionTool tool = (DocumentCollectionTool) functionList.get(0); + Assert.assertEquals(BigInteger.valueOf(101), tool.getKnowledgeId()); + Assert.assertEquals(RetrievalMode.KEYWORD, tool.getRetrievalMode()); + } + + private void injectAvailabilityService(BotServiceImpl service, ChatTimeToolAvailabilityService availabilityService) throws Exception { + Field field = BotServiceImpl.class.getDeclaredField("chatTimeToolAvailabilityService"); + field.setAccessible(true); + field.set(service, availabilityService); + } + + private void injectDocumentCollectionService(BotServiceImpl service, tech.easyflow.ai.service.DocumentCollectionService documentCollectionService) throws Exception { + Field field = BotServiceImpl.class.getDeclaredField("documentCollectionService"); + field.setAccessible(true); + field.set(service, documentCollectionService); + } + + private ChatTimeToolAvailabilityService buildAvailabilityService(RoleCategoryAccessSnapshot accessSnapshot, + Set deptIds) { + return new ChatTimeToolAvailabilityServiceImpl(List.of( + new ChatTimeKnowledgeAvailabilityResolver( + new KnowledgeVisibilityQueryHelper(), + mockCategoryPermissionService(accessSnapshot), + mockSysDeptService(deptIds) + ) + )); + } + + private ChatTimeToolAvailabilityContext buildContext(long accountId, long deptId) { + LoginAccount loginAccount = new LoginAccount(); + loginAccount.setId(BigInteger.valueOf(accountId)); + loginAccount.setDeptId(BigInteger.valueOf(deptId)); + ChatTimeToolAvailabilityContext context = new ChatTimeToolAvailabilityContext(); + context.setLoginAccount(loginAccount); + return context; + } + + private BotDocumentCollection buildBinding(long knowledgeId, + long createdBy, + long categoryId, + String visibilityScope, + RetrievalMode retrievalMode) { + DocumentCollection knowledge = buildKnowledge(knowledgeId, createdBy, categoryId, visibilityScope); + BotDocumentCollection binding = new BotDocumentCollection(); + binding.setDocumentCollectionId(BigInteger.valueOf(knowledgeId)); + binding.setKnowledge(knowledge); + binding.setRetrievalMode(retrievalMode); + return binding; + } + + private DocumentCollection buildKnowledge(long knowledgeId, + long createdBy, + long categoryId, + String visibilityScope) { + DocumentCollection knowledge = new DocumentCollection(); + knowledge.setId(BigInteger.valueOf(knowledgeId)); + knowledge.setTitle("knowledge-" + knowledgeId); + knowledge.setDescription("desc-" + knowledgeId); + knowledge.setCreatedBy(BigInteger.valueOf(createdBy)); + knowledge.setCategoryId(BigInteger.valueOf(categoryId)); + knowledge.setVisibilityScope(visibilityScope); + return knowledge; + } + + private Map buildPublishedBinding(long knowledgeId, RetrievalMode retrievalMode) { + Map binding = new HashMap<>(); + binding.put("knowledgeId", String.valueOf(knowledgeId)); + binding.put("retrievalMode", retrievalMode.name()); + return binding; + } + + private tech.easyflow.ai.service.DocumentCollectionService mockDocumentCollectionService(DocumentCollection... collections) { + Map knowledgeMap = new HashMap<>(); + for (DocumentCollection collection : collections) { + knowledgeMap.put(collection.getId(), collection); + } + return (tech.easyflow.ai.service.DocumentCollectionService) Proxy.newProxyInstance( + tech.easyflow.ai.service.DocumentCollectionService.class.getClassLoader(), + new Class[]{tech.easyflow.ai.service.DocumentCollectionService.class}, + (proxy, method, args) -> { + if ("getPublishedById".equals(method.getName()) && args != null && args.length == 1 && args[0] instanceof BigInteger knowledgeId) { + return knowledgeMap.get(knowledgeId); + } + return defaultValue(method.getReturnType()); + } + ); + } + + private CategoryPermissionService mockCategoryPermissionService(RoleCategoryAccessSnapshot accessSnapshot) { + return (CategoryPermissionService) Proxy.newProxyInstance( + CategoryPermissionService.class.getClassLoader(), + new Class[]{CategoryPermissionService.class}, + (proxy, method, args) -> { + if ("getAccess".equals(method.getName())) { + return accessSnapshot; + } + if ("isSuperAdmin".equals(method.getName())) { + return accessSnapshot.isSuperAdmin(); + } + return defaultValue(method.getReturnType()); + } + ); + } + + private SysDeptService mockSysDeptService(Set deptIds) { + Set readableDeptIds = deptIds == null ? Collections.emptySet() : deptIds; + return (SysDeptService) Proxy.newProxyInstance( + SysDeptService.class.getClassLoader(), + new Class[]{SysDeptService.class}, + (proxy, method, args) -> { + if ("getSelfAndAncestorDeptIds".equals(method.getName())) { + return readableDeptIds; + } + if ("canUserAccessDeptScopedResource".equals(method.getName())) { + BigInteger resourceDeptId = (BigInteger) args[1]; + return readableDeptIds.contains(resourceDeptId); + } + return defaultValue(method.getReturnType()); + } + ); + } + + private Set setOf(BigInteger... values) { + Set result = new LinkedHashSet<>(); + Collections.addAll(result, values); + return result; + } + + private Object defaultValue(Class returnType) { + if (returnType == boolean.class) { + return false; + } + if (returnType == int.class) { + return 0; + } + if (returnType == long.class) { + return 0L; + } + return null; + } +}