feat(mcp): 新增身份与能力自检工具 integration.context.get (REQ-20260911-0012) #10
@@ -81,6 +81,7 @@ xiaoqu-agent-service/
|
||||
| `agent.count` | system | read | 查 m0 库 `agent` 表 `soft_delete=0` 总数 |
|
||||
| `agent.list.query` | customer | read | 代理商分页查询。入参 id/name/level/channelNo/province/city/serialnumber/page/rows;出参 id/name/level/channelNo/serialnumber/supervisorName/province/city/countyseat/contact/phone/agencyarea/memo/createTime。行权限:isagent=1 只查直属下级,id 精确查可看自身。supervisorName 批量 IN 富化 |
|
||||
| `property.list.query` | customer | read | 物业(客户)分页查询(m0 properyt 表)。入参 name/agentId/serialnumber/province/city/contact/phone/page/rows;出参 id/name/serialnumber/agentName/createName/province/city/countyseat/address/contact/phone/createTime。行权限走 PermissionService 完整版 getByIsAgent 语义(不含 enterprise_type=2 限制,与老 /property/listByPage 一致)。agentName/createName 批量 IN 富化 |
|
||||
| `integration.context.get` | integration | read | 身份与能力自检(REQ-20260911-0012)。无入参;返回 tenant_id(作用域编码映射)/principal_type/subject_id/scopes(可调用工具集合,与强制校验同源实时计算)/data_scope_version(token_scope 绑定最大修改时间 epoch 毫秒)/capability_levels(QUERY/SYNC,按 *.changes.list 数据驱动)/server_time |
|
||||
|
||||
分页统一规范:page 从 1 开始,rows 默认 20、上限 100,超出截断为 100 并在返回 `notice` 字段提示。
|
||||
|
||||
|
||||
@@ -427,6 +427,29 @@
|
||||
|
||||
---
|
||||
|
||||
## 14. integration.context.get
|
||||
|
||||
身份与能力自检(REQ-20260911-0012):返回当前 Token 的集成上下文。所有数据每次调用实时取库,不缓存权限。
|
||||
|
||||
- 分类:integration | 类型:读
|
||||
- 数据来源:svc 库 `agent_api_key` / `token_scope` / `agent_scope` + ToolGuard 实时能力计算(与 tools/call 强制校验同源)
|
||||
|
||||
**入参**:无
|
||||
|
||||
**出参**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| tenant_id | string | Token 所属数据域标识 = 启用状态的作用域编码(排序后逗号拼接;当前签发规则为一作用域一 Token) |
|
||||
| principal_type | string | `user_delegated`(用户委托)/ `tenant_service`(服务主体)。当前按 token 绑定用户推导;`agent_api_key.principal_type` 列由后续需求(5629)补充后切换为读列 |
|
||||
| subject_id | number | 用户 ID 或服务主体 ID |
|
||||
| scopes | string[] | 该 Token 允许调用的工具名集合(排序),与 tools/call 强制校验结果严格一致 |
|
||||
| data_scope_version | number | 数据权限版本:token_scope 绑定集合最大 modify_time(空取 create_time)的 epoch 毫秒;无绑定为 0 |
|
||||
| capability_levels | string[] | `QUERY` / `SYNC`:能力集合中含 `*.changes.list` 工具时具备 SYNC(数据驱动);无能力时为空数组 |
|
||||
| server_time | string | 带时区的服务器时间(ISO_OFFSET_DATE_TIME) |
|
||||
|
||||
---
|
||||
|
||||
## 附录:在线查看工具清单
|
||||
|
||||
```bash
|
||||
|
||||
@@ -16,10 +16,10 @@ WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'operation');
|
||||
|
||||
-- 作用域-工具:sys.ping / agent.count / agent.list.query / property.list.query / agent.detail.query / property.detail.query
|
||||
-- / mall.order.list.query / mall.order.detail.query / mall.order.logistics.query
|
||||
-- / contract.list.query / contract.detail.query
|
||||
-- / contract.list.query / contract.detail.query / integration.context.get
|
||||
INSERT INTO scope_tool (scope_id, tool_id, create_id)
|
||||
SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t
|
||||
WHERE s.code = 'operation' AND t.name IN ('sys.ping', 'agent.count', 'agent.list.query', 'property.list.query', 'agent.detail.query', 'property.detail.query', 'mall.order.list.query', 'mall.order.detail.query', 'mall.order.logistics.query', 'contract.list.query', 'contract.detail.query')
|
||||
WHERE s.code = 'operation' AND t.name IN ('sys.ping', 'agent.count', 'agent.list.query', 'property.list.query', 'agent.detail.query', 'property.detail.query', 'mall.order.list.query', 'mall.order.detail.query', 'mall.order.logistics.query', 'contract.list.query', 'contract.detail.query', 'integration.context.get')
|
||||
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
|
||||
|
||||
-- 作用域:hardware(出纸机查询)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package me.iiv.agent.mcp;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import me.iiv.agent.entity.svc.AgentApiKey;
|
||||
import me.iiv.agent.entity.svc.AgentScope;
|
||||
import me.iiv.agent.entity.svc.TokenScope;
|
||||
import me.iiv.agent.mapper.svc.AgentScopeMapper;
|
||||
import me.iiv.agent.mapper.svc.TokenScopeMapper;
|
||||
import me.iiv.agent.security.AuthContext;
|
||||
import me.iiv.agent.security.ToolGuard;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* integration.context.get 的上下文计算(REQ-20260911-0012)。
|
||||
* 所有数据每次调用实时取库(token_scope / agent_scope / ToolGuard 同链路),不缓存权限。
|
||||
*/
|
||||
@Component
|
||||
public class IntegrationContextService {
|
||||
|
||||
public static final String PRINCIPAL_USER_DELEGATED = "user_delegated";
|
||||
public static final String PRINCIPAL_TENANT_SERVICE = "tenant_service";
|
||||
|
||||
public static final String CAPABILITY_QUERY = "QUERY";
|
||||
public static final String CAPABILITY_SYNC = "SYNC";
|
||||
|
||||
/** 增量同步工具命名约定:出现该后缀即视为具备 SYNC 能力(数据驱动,不硬编码工具清单) */
|
||||
static final String CHANGES_TOOL_SUFFIX = ".changes.list";
|
||||
|
||||
final TokenScopeMapper tokenScopeMapper;
|
||||
final AgentScopeMapper agentScopeMapper;
|
||||
final ToolGuard toolGuard;
|
||||
|
||||
public IntegrationContextService(TokenScopeMapper tokenScopeMapper, AgentScopeMapper agentScopeMapper,
|
||||
ToolGuard toolGuard) {
|
||||
this.tokenScopeMapper = tokenScopeMapper;
|
||||
this.agentScopeMapper = agentScopeMapper;
|
||||
this.toolGuard = toolGuard;
|
||||
}
|
||||
|
||||
public Map<String, Object> buildContext(AuthContext ctx) {
|
||||
Long tokenId = ctx.getTokenId();
|
||||
List<TokenScope> bindings = tokenScopeMapper.selectList(new LambdaQueryWrapper<TokenScope>()
|
||||
.eq(TokenScope::getSoftDelete, 0)
|
||||
.eq(TokenScope::getTokenId, tokenId));
|
||||
List<AgentScope> scopes = enabledScopes(bindings);
|
||||
// 与 tools/call 强制校验(GuardedToolCallback → ToolGuard)走同一实时查询,保证一致
|
||||
Set<String> allowedTools = toolGuard.allowedToolNames(tokenId);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("tenant_id", tenantId(scopes));
|
||||
result.put("principal_type", resolvePrincipalType(ctx));
|
||||
result.put("subject_id", subjectId(ctx));
|
||||
result.put("scopes", new ArrayList<>(new TreeSet<>(allowedTools)));
|
||||
result.put("data_scope_version", dataScopeVersion(bindings));
|
||||
result.put("capability_levels", capabilityLevels(allowedTools));
|
||||
result.put("server_time", OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主体类型判定(5629 切换点):agent_api_key 后续会新增 principal_type 列
|
||||
* (REQ-20260911-0013 的 Flyway),届时只需改本方法为读列。
|
||||
* 当前按现有数据推导:token 绑定了用户(user_id 非空且人已解析)即 user_delegated,
|
||||
* 否则 tenant_service(存量 ak 均为 ensure 签发的用户 Token,不会走到该分支)。
|
||||
*/
|
||||
String resolvePrincipalType(AuthContext ctx) {
|
||||
AgentApiKey token = ctx.getToken();
|
||||
boolean hasUser = ctx.getUser() != null || (token != null && token.getUserId() != null);
|
||||
return hasUser ? PRINCIPAL_USER_DELEGATED : PRINCIPAL_TENANT_SERVICE;
|
||||
}
|
||||
|
||||
/** 数据权限版本:token_scope 绑定集合的最大 modify_time(空取 create_time)转 epoch 毫秒;无绑定为 0 */
|
||||
long dataScopeVersion(List<TokenScope> bindings) {
|
||||
long max = 0L;
|
||||
for (TokenScope binding : bindings) {
|
||||
Date time = binding.getModifyTime() != null ? binding.getModifyTime() : binding.getCreateTime();
|
||||
if (time != null) {
|
||||
max = Math.max(max, time.getTime());
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/** 能力等级:能力集合内含 *.changes.list 工具 → SYNC;含其他工具 → QUERY;无能力 → 空 */
|
||||
List<String> capabilityLevels(Collection<String> allowedTools) {
|
||||
List<String> levels = new ArrayList<>();
|
||||
if (allowedTools.stream().anyMatch(name -> !name.endsWith(CHANGES_TOOL_SUFFIX))) {
|
||||
levels.add(CAPABILITY_QUERY);
|
||||
}
|
||||
if (allowedTools.stream().anyMatch(name -> name.endsWith(CHANGES_TOOL_SUFFIX))) {
|
||||
levels.add(CAPABILITY_SYNC);
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
/** 租户标识 = 启用的作用域编码(排序后逗号拼接);当前签发规则为一作用域一 Token,多绑定时全部列出 */
|
||||
String tenantId(List<AgentScope> scopes) {
|
||||
return scopes.stream().map(AgentScope::getCode).sorted().collect(Collectors.joining(","));
|
||||
}
|
||||
|
||||
private Long subjectId(AuthContext ctx) {
|
||||
if (ctx.getUser() != null) {
|
||||
return ctx.getUser().getId();
|
||||
}
|
||||
return ctx.getToken() == null ? null : ctx.getToken().getUserId();
|
||||
}
|
||||
|
||||
private List<AgentScope> enabledScopes(List<TokenScope> bindings) {
|
||||
if (bindings.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Long> scopeIds = bindings.stream().map(TokenScope::getScopeId).distinct().collect(Collectors.toList());
|
||||
return agentScopeMapper.selectList(new LambdaQueryWrapper<AgentScope>()
|
||||
.eq(AgentScope::getSoftDelete, 0)
|
||||
.eq(AgentScope::getStatus, 1)
|
||||
.in(AgentScope::getId, scopeIds));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import me.iiv.agent.mcp.IntegrationContextService;
|
||||
import me.iiv.agent.mcp.ToolObject;
|
||||
import me.iiv.agent.mcp.XqTool;
|
||||
import me.iiv.agent.security.AuthContext;
|
||||
import me.iiv.agent.security.AuthContextHolder;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 集成域工具:integration.context.get(REQ-20260911-0012)。
|
||||
* 身份与能力自检:返回当前 ak 的租户标识、主体类型、能力集合与数据权限版本,
|
||||
* 数据全部实时取自鉴权上下文与 svc 库绑定关系,不缓存。
|
||||
*/
|
||||
@Component
|
||||
public class IntegrationTools implements ToolObject {
|
||||
|
||||
final IntegrationContextService integrationContextService;
|
||||
|
||||
public IntegrationTools(IntegrationContextService integrationContextService) {
|
||||
this.integrationContextService = integrationContextService;
|
||||
}
|
||||
|
||||
@XqTool(category = "integration", kind = "read")
|
||||
@Tool(name = "integration.context.get", description = "身份与能力自检:返回当前 Token 的集成上下文。"
|
||||
+ "tenant_id=Token 所属数据域标识(映射启用状态的作用域编码,多个以逗号拼接);"
|
||||
+ "principal_type=user_delegated(用户委托)或 tenant_service(服务主体);"
|
||||
+ "subject_id=用户 ID 或服务主体 ID;"
|
||||
+ "scopes=该 Token 允许调用的工具集合(与 tools/call 强制校验同源实时计算);"
|
||||
+ "data_scope_version=数据权限版本(token_scope 绑定集合最大修改时间的 epoch 毫秒,无绑定为 0);"
|
||||
+ "capability_levels=QUERY 或 SYNC(能力集合中含 *.changes.list 工具时具备 SYNC);"
|
||||
+ "server_time=带时区的服务器时间。无入参。")
|
||||
public Map<String, Object> getContext() {
|
||||
AuthContext ctx = AuthContextHolder.get();
|
||||
if (ctx == null) {
|
||||
throw new IllegalStateException("鉴权上下文缺失");
|
||||
}
|
||||
return integrationContextService.buildContext(ctx);
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package me.iiv.agent.mcp;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.svc.AgentApiKey;
|
||||
import me.iiv.agent.entity.svc.AgentScope;
|
||||
import me.iiv.agent.entity.svc.TokenScope;
|
||||
import me.iiv.agent.mapper.svc.AgentScopeMapper;
|
||||
import me.iiv.agent.mapper.svc.TokenScopeMapper;
|
||||
import me.iiv.agent.security.AuthContext;
|
||||
import me.iiv.agent.security.ToolGuard;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@SuppressWarnings("unchecked")
|
||||
class IntegrationContextServiceTest {
|
||||
|
||||
@Mock
|
||||
TokenScopeMapper tokenScopeMapper;
|
||||
@Mock
|
||||
AgentScopeMapper agentScopeMapper;
|
||||
@Mock
|
||||
ToolGuard toolGuard;
|
||||
|
||||
@Test
|
||||
void buildContextReturnsFullStructure() {
|
||||
Date modifyTime = new Date(1_800_000_000_000L);
|
||||
when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(List.of(binding(3L, modifyTime, null)));
|
||||
when(agentScopeMapper.selectList(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(List.of(scope(3L, "operation")));
|
||||
when(toolGuard.allowedToolNames(7L)).thenReturn(Set.of("agent.count", "sys.ping"));
|
||||
IntegrationContextService service = service();
|
||||
|
||||
Map<String, Object> result = service.buildContext(context(token(7L, 42L), user(42L)));
|
||||
|
||||
assertEquals("operation", result.get("tenant_id"));
|
||||
assertEquals("user_delegated", result.get("principal_type"));
|
||||
assertEquals(42L, result.get("subject_id"));
|
||||
assertEquals(List.of("agent.count", "sys.ping"), result.get("scopes"));
|
||||
assertEquals(1_800_000_000_000L, result.get("data_scope_version"));
|
||||
assertEquals(List.of("QUERY"), result.get("capability_levels"));
|
||||
// server_time 必须是带时区的合法 ISO_OFFSET_DATE_TIME
|
||||
OffsetDateTime.parse((String) result.get("server_time"));
|
||||
assertEquals(7, result.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void principalTypeIsTenantServiceWhenNoUserBound() {
|
||||
when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
when(toolGuard.allowedToolNames(7L)).thenReturn(Set.of());
|
||||
IntegrationContextService service = service();
|
||||
AuthContext ctx = context(token(7L, null), null);
|
||||
|
||||
assertEquals("tenant_service", service.resolvePrincipalType(ctx));
|
||||
assertNull(service.buildContext(ctx).get("subject_id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void principalTypeIsUserDelegatedWhenUserBound() {
|
||||
IntegrationContextService service = service();
|
||||
|
||||
assertEquals("user_delegated", service.resolvePrincipalType(context(token(7L, 42L), user(42L))));
|
||||
// 人未加载但 token 绑定了 user_id,仍按用户委托判定(5629 切列前以绑定关系为准)
|
||||
assertEquals("user_delegated", service.resolvePrincipalType(context(token(7L, 42L), null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void capabilityLevelsAreDataDriven() {
|
||||
IntegrationContextService service = service();
|
||||
|
||||
assertEquals(List.of("QUERY"), service.capabilityLevels(Set.of("sys.ping")));
|
||||
assertEquals(List.of("QUERY", "SYNC"),
|
||||
service.capabilityLevels(Set.of("sys.ping", "agent.changes.list")));
|
||||
assertEquals(List.of("SYNC"), service.capabilityLevels(Set.of("agent.changes.list")));
|
||||
assertEquals(List.of(), service.capabilityLevels(Set.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dataScopeVersionUsesMaxModifyTime() {
|
||||
IntegrationContextService service = service();
|
||||
|
||||
long version = service.dataScopeVersion(List.of(
|
||||
binding(3L, new Date(1000L), null),
|
||||
binding(4L, new Date(3000L), null),
|
||||
binding(5L, new Date(2000L), null)));
|
||||
assertEquals(3000L, version);
|
||||
// modify_time 为空时回退 create_time
|
||||
assertEquals(500L, service.dataScopeVersion(List.of(binding(3L, null, new Date(500L)))));
|
||||
assertEquals(0L, service.dataScopeVersion(List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tenantIdMapsSortedScopeCodes() {
|
||||
IntegrationContextService service = service();
|
||||
|
||||
assertEquals("hardware,operation",
|
||||
service.tenantId(List.of(scope(1L, "operation"), scope(2L, "hardware"))));
|
||||
assertEquals("", service.tenantId(List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildContextWithNoBindingsReturnsEmptyScopeAndZeroVersion() {
|
||||
when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
when(toolGuard.allowedToolNames(7L)).thenReturn(Set.of());
|
||||
IntegrationContextService service = service();
|
||||
|
||||
Map<String, Object> result = service.buildContext(context(token(7L, 42L), user(42L)));
|
||||
|
||||
assertEquals("", result.get("tenant_id"));
|
||||
assertEquals(List.of(), result.get("scopes"));
|
||||
assertEquals(0L, result.get("data_scope_version"));
|
||||
assertEquals(List.of(), result.get("capability_levels"));
|
||||
}
|
||||
|
||||
private IntegrationContextService service() {
|
||||
return new IntegrationContextService(tokenScopeMapper, agentScopeMapper, toolGuard);
|
||||
}
|
||||
|
||||
private static AuthContext context(AgentApiKey token, User user) {
|
||||
return new AuthContext(token, user);
|
||||
}
|
||||
|
||||
private static AgentApiKey token(Long id, Long userId) {
|
||||
AgentApiKey token = new AgentApiKey();
|
||||
token.setId(id);
|
||||
token.setUserId(userId);
|
||||
return token;
|
||||
}
|
||||
|
||||
private static User user(Long id) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
return user;
|
||||
}
|
||||
|
||||
private static TokenScope binding(Long scopeId, Date modifyTime, Date createTime) {
|
||||
TokenScope binding = new TokenScope();
|
||||
binding.setScopeId(scopeId);
|
||||
binding.setModifyTime(modifyTime);
|
||||
binding.setCreateTime(createTime);
|
||||
return binding;
|
||||
}
|
||||
|
||||
private static AgentScope scope(Long id, String code) {
|
||||
AgentScope scope = new AgentScope();
|
||||
scope.setId(id);
|
||||
scope.setCode(code);
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user