feat: 收口聊天时知识库工具可见性
- 新增 chatTime 工具可见性抽象与知识库 resolver - 聊天装配链路按当前用户过滤知识库工具并补齐调用兜底 - 补充聊天时显式登录快照与对应后端测试
This commit is contained in:
@@ -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<BigInteger> 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<BigInteger> deptIds) {
|
||||
Set<BigInteger> 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<BigInteger> setOf(BigInteger... values) {
|
||||
Set<BigInteger> 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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<Object> 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<BigInteger> 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<BigInteger> deptIds) {
|
||||
Set<BigInteger> 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> 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<BigInteger> setOf(BigInteger... values) {
|
||||
Set<BigInteger> 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<Document> searchResult;
|
||||
private int searchCount;
|
||||
private KnowledgeRetrievalRequest lastRequest;
|
||||
|
||||
private TestDocumentCollectionService(DocumentCollection knowledge, List<Document> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Tool> 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<Tool> 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<String, Object> snapshot = new HashMap<>();
|
||||
snapshot.put("knowledgeBindings", List.of(
|
||||
buildPublishedBinding(101, RetrievalMode.KEYWORD),
|
||||
buildPublishedBinding(102, RetrievalMode.HYBRID)
|
||||
));
|
||||
runtimeBot.setPublishedSnapshotJson(snapshot);
|
||||
List<Tool> 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<BigInteger> 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<String, Object> buildPublishedBinding(long knowledgeId, RetrievalMode retrievalMode) {
|
||||
Map<String, Object> 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<BigInteger, DocumentCollection> 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<BigInteger> deptIds) {
|
||||
Set<BigInteger> 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<BigInteger> setOf(BigInteger... values) {
|
||||
Set<BigInteger> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user