feat(mcp): 新增身份与能力自检工具 integration.context.get (REQ-20260911-0012) #10

Merged
wangheng merged 1 commits from feat/REQ-5627 into main 2026-09-11 23:11:24 +08:00
6 changed files with 364 additions and 2 deletions
+1
View File
@@ -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
+2 -2
View File
@@ -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);
}
}
@@ -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;
}
}