feat(sync): add pre-delivery contract verification
This commit is contained in:
@@ -45,7 +45,7 @@ tools/call integration.context.get
|
||||
|
||||
## 3. 后台通道:每日定时扫描(增量同步)
|
||||
|
||||
三个工具:`users.changes.list` / `properties.changes.list` / `contracts.changes.list`。
|
||||
三个变化流工具:`users.changes.list` / `properties.changes.list` / `contracts.changes.list`。
|
||||
|
||||
入参只有三个(**禁止传 tenant_id/scope**,传了直接 PERMISSION_DENIED):
|
||||
|
||||
@@ -89,6 +89,20 @@ loop:
|
||||
- 不要拿 `version` 字段做并发控制——它由 `modify_time` 派生,只用于调试核对。
|
||||
- 不要把扫描任务和销售在线查询混用同一个 Token——服务 Token 调 QUERY 工具会被拒(scope 白名单),这是设计如此。
|
||||
|
||||
### 发送前复核(不能省略)
|
||||
|
||||
日同步只负责发现候选。CRM 在晨报、午报或日结真正入推送队列前,必须使用同一个服务 Token 批量调用:
|
||||
|
||||
```text
|
||||
tools/call contracts.current.batch_get
|
||||
arguments: {"contractIds":["1001","1002"]}
|
||||
```
|
||||
|
||||
- 每批 1—200 个稳定合同 ID,返回 `checked_at/count/items/not_found_ids`。
|
||||
- `items` 使用与合同变化流相同的公开字段;软删合同返回删除标记,查无记录进入 `not_found_ids`。
|
||||
- CRM 必须逐项复核状态、到期日、负责人、客户归属、提醒配置和续约结果。任一变化、缺失、删除、响应不完整、证据时间异常或服务不可用,对应合同本次都不得发送。
|
||||
- 该工具属于 SYNC 能力和限流档,调用方传 `tenant_id/tenantId/scope` 会被拒绝;数据域只能来自服务 Token。
|
||||
|
||||
## 4. 前台通道:销售在线查询(实时 ACL)
|
||||
|
||||
```
|
||||
@@ -107,7 +121,7 @@ loop:
|
||||
| | 前台(销售点查) | 后台(定时扫描) |
|
||||
|---|---|---|
|
||||
| Token 类型 | user_delegated | tenant_service |
|
||||
| 可用工具 | contract.list/detail.query 等 QUERY | *.changes.list + context.get |
|
||||
| 可用工具 | contract.list/detail.query 等 QUERY | *.changes.list + contracts.current.batch_get + context.get |
|
||||
| 数据范围 | 实时 ACL 行过滤 | 管理员授权的 sync scope |
|
||||
| 限流 | 常规档 | SYNC 独立档,互不占配额 |
|
||||
| tenant_id 入参 | 不允许 | 不允许,从 Token 推导 |
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| SaaS MCP | 服务身份、源租户/数据域、工具授权、源系统行级权限、用户/物业/合同标准变化流、稳定游标、审计与标准错误 | 到期规则、经营优先级、AI决策、经营事项状态机、销售页面、APNs推送 |
|
||||
| 小趣 AI / CRM | 租户连接、数据镜像、身份与客户映射、确定性规则、仅把命中数据交给AI、事项生成/流转、展示、操作、反馈、推送前复核 | 猜测源租户、绕过MCP权限、兼容不符合冻结Schema的错误字段 |
|
||||
|
||||
双方唯一连接面是版本化接口契约:`integration.context.get`、三条 `*.changes.list`、统一错误信封,以及真实端到端验收。任何一侧内部实现都不能成为另一侧的隐式依赖。
|
||||
双方唯一连接面是版本化接口契约:`integration.context.get`、三条 `*.changes.list`、`contracts.current.batch_get`、统一错误信封,以及真实端到端验收。任何一侧内部实现都不能成为另一侧的隐式依赖。
|
||||
|
||||
## 2. 当前结论
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
- `X-Open-Key` 管理、服务Token签发、Token作用域、工具白名单、审计和限流。
|
||||
- `integration.context.get`。
|
||||
- `users.changes.list`、`properties.changes.list`、`contracts.changes.list`及统一分页框架。
|
||||
- `contracts.current.batch_get`发送前批量回源复核已在本地实现;沿用SYNC身份、权限、限流和审计边界。
|
||||
- 基于 `(modify_time, id)` 的分页、软删标记、错误码和基础单元测试。
|
||||
|
||||
尚未通过的不是“小趣AI页面问题”,而是MCP公开数据契约与可靠同步语义。小趣AI侧不得通过放宽校验来掩盖这些问题。
|
||||
@@ -61,7 +62,7 @@
|
||||
|
||||
已完成:新增独立公开DTO,将三类稳定ID统一为字符串;`mobile`改为`verified_mobile`;合同状态转换为公开枚举;`expire_remind`转换为布尔值;补齐`remind_days`;变化页使用统一`stream`字段。未知合同状态按`DATA_INTEGRITY_ERROR`失败关闭,不伪造业务语义。
|
||||
|
||||
验证:Java编译和MCP服务全量测试通过,`220`项测试、`0`失败、`0`错误。尚需部署后的真实数据样例和小趣Gateway严格消费者契约联调,完成前仍不算生产通过。
|
||||
验证:新增发送前复核后,Java编译、管理前端构建及MCP服务全量`225`项测试通过,`0`失败、`0`错误。尚需部署后的真实数据样例和小趣Gateway严格消费者契约联调,完成前仍不算生产通过。
|
||||
|
||||
验收:用真实样例对三条流执行消费者契约测试;Gateway无需兼容代码即可全部通过,未知字段、未知状态和错误类型必须失败。
|
||||
|
||||
@@ -95,6 +96,14 @@
|
||||
|
||||
验收:修改任一影响可见数据的授权后,旧轮立即失败关闭,新轮在新数据域版本下重建。
|
||||
|
||||
### P0-6 发送前权威复核(本地已实现,待部署联调)
|
||||
|
||||
已实现`contracts.current.batch_get`:一次读取1—200份合同当前事实,按输入顺序返回已找到记录,并明确返回`not_found_ids`;软删记录不会伪装成“查无数据”。该工具沿用SYNC服务身份,禁止调用方覆盖数据域,使用SYNC独立限流档并进入统一审计。
|
||||
|
||||
小趣AI侧已经按同一契约实现短时发送凭证:只有状态、到期日、负责人、客户归属、提醒字段及续约结果与本地候选一致才允许R04进入推送;缺失、删除、字段变化、响应不完整或MCP不可用均失败关闭,R03不受影响。
|
||||
|
||||
尚未完成:本分支未推送和部署;生产`sync`作用域还必须绑定该新工具;需要用真实合同验证负责人变更、删除、到期日修改和服务不可用四类反向场景。
|
||||
|
||||
## 4. 依赖顺序
|
||||
|
||||
1. 冻结服务端`source_domain_id`配置、单数据域范围和合同状态/提醒字段字典。
|
||||
@@ -113,6 +122,7 @@
|
||||
| 服务密钥与Token基础生命周期 | 已完成 | MCP | 在租户模型完成后升级幂等键 |
|
||||
| 源数据域标识与服务主体隔离 | 未完成,P0阻塞 | MCP | 增加稳定部署数据域标识和Open Client关联 |
|
||||
| 三流公开字段Schema | 本地代码完成,待部署联调 | MCP | 部署后用真实三流样例跑Gateway严格契约测试 |
|
||||
| 发送前合同当前状态批量复核 | 本地代码完成,待部署联调 | MCP + 小趣AI | 绑定sync作用域并完成四类真实反向用例 |
|
||||
| 可靠变化日志与稳定快照 | 未完成,P0阻塞 | MCP | 设计Outbox/CDC及保留策略 |
|
||||
| 权限版本与交接位点生命周期 | 未完成,P0阻塞 | MCP | 与租户/变化日志模型一起实现 |
|
||||
| 小趣AI连接与消费代码 | 已完成,待部署联调 | 小趣AI | 见小趣AI仓库`PROJECT.md` |
|
||||
|
||||
@@ -40,10 +40,10 @@ INSERT INTO agent_scope (name, code, memo, status, create_id)
|
||||
SELECT '数据同步', 'sync', 'SYNC 增量同步作用域(5628 注册 *.changes.list 工具后绑定,仅服务 Token 使用)', 1, 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'sync');
|
||||
|
||||
-- sync 作用域-工具:integration.context.get(服务 Token 自检入口)+ 三个 changes 工具(REQ-20260911-0013)
|
||||
-- sync 作用域-工具:服务 Token 自检、三条变化流及发送前合同复核
|
||||
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 = 'sync' AND t.name IN ('integration.context.get', 'users.changes.list', 'properties.changes.list', 'contracts.changes.list')
|
||||
WHERE s.code = 'sync' AND t.name IN ('integration.context.get', 'users.changes.list', 'properties.changes.list', 'contracts.changes.list', 'contracts.current.batch_get')
|
||||
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -93,12 +93,12 @@ public class GuardedToolCallback implements ToolCallback {
|
||||
}
|
||||
|
||||
/**
|
||||
* SYNC 工具(*.changes.list)数据域硬约束:tenant_id/scope 入参一律 PERMISSION_DENIED。
|
||||
* SYNC 数据工具的数据域硬约束:tenant_id/scope 入参一律 PERMISSION_DENIED。
|
||||
* 工具方法签名本就不含这些参数,这里拦的是调用方在原始 JSON 里多传的键
|
||||
* (Spring AI 对未知参数默认宽松,不能依赖反序列化兜底)。
|
||||
*/
|
||||
private void rejectForbiddenSyncParams(String toolName, String toolInput) {
|
||||
if (!toolName.endsWith(IntegrationContextService.CHANGES_TOOL_SUFFIX)) {
|
||||
if (!IntegrationContextService.isSyncDataTool(toolName)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -35,8 +35,13 @@ public class IntegrationContextService {
|
||||
public static final String CAPABILITY_QUERY = "QUERY";
|
||||
public static final String CAPABILITY_SYNC = "SYNC";
|
||||
|
||||
/** 增量同步工具命名约定:出现该后缀即视为具备 SYNC 能力(数据驱动,不硬编码工具清单) */
|
||||
/** 后台数据工具命名约定:变化流与发送前批量复核均属于 SYNC 能力。 */
|
||||
static final String CHANGES_TOOL_SUFFIX = ".changes.list";
|
||||
static final String CURRENT_BATCH_TOOL_SUFFIX = ".current.batch_get";
|
||||
|
||||
static boolean isSyncDataTool(String name) {
|
||||
return name.endsWith(CHANGES_TOOL_SUFFIX) || name.endsWith(CURRENT_BATCH_TOOL_SUFFIX);
|
||||
}
|
||||
|
||||
final TokenScopeMapper tokenScopeMapper;
|
||||
final AgentScopeMapper agentScopeMapper;
|
||||
@@ -93,10 +98,10 @@ public class IntegrationContextService {
|
||||
/** 能力等级:能力集合内含 *.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))) {
|
||||
if (allowedTools.stream().anyMatch(name -> !isSyncDataTool(name))) {
|
||||
levels.add(CAPABILITY_QUERY);
|
||||
}
|
||||
if (allowedTools.stream().anyMatch(name -> name.endsWith(CHANGES_TOOL_SUFFIX))) {
|
||||
if (allowedTools.stream().anyMatch(IntegrationContextService::isSyncDataTool)) {
|
||||
levels.add(CAPABILITY_SYNC);
|
||||
}
|
||||
return levels;
|
||||
|
||||
@@ -28,6 +28,9 @@ public enum McpErrorCode {
|
||||
/** 调用方声明的 schema_version 不受支持 */
|
||||
SCHEMA_VERSION_UNSUPPORTED,
|
||||
|
||||
/** 工具入参格式、数量或取值不符合公开契约 */
|
||||
INVALID_ARGUMENT,
|
||||
|
||||
/** 数据处理过程中发现不一致或未预期异常(兜底码,内部细节只落服务端日志) */
|
||||
DATA_INTEGRITY_ERROR
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 工具调用限流(REQ-20260911-0013 资源隔离):按 Token 分档计数——
|
||||
* *.changes.list 走 SYNC 档位,其余走 QUERY 档位,互不占配额。
|
||||
* 后台数据工具走 SYNC 档位,其余走 QUERY 档位,互不占配额。
|
||||
* Redis 固定窗口(分钟),超额抛 RATE_LIMITED;Redis 不可用时放行并告警
|
||||
* (限流是保护措施,不应成为可用性单点)。
|
||||
* 配额 0 或负数 = 关闭该档位。
|
||||
@@ -38,7 +38,7 @@ public class RateLimitService {
|
||||
if (tokenId == null) {
|
||||
return;
|
||||
}
|
||||
boolean sync = toolName.endsWith(IntegrationContextService.CHANGES_TOOL_SUFFIX);
|
||||
boolean sync = IntegrationContextService.isSyncDataTool(toolName);
|
||||
int limit = sync ? syncPerMinute : queryPerMinute;
|
||||
if (limit <= 0) {
|
||||
return;
|
||||
|
||||
@@ -13,7 +13,9 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -76,6 +78,37 @@ public class ContractsSyncEntity extends AbstractSyncEntity implements SyncEntit
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current authoritative rows for pre-delivery verification.
|
||||
* Soft-deleted rows are intentionally retained so consumers can cancel a
|
||||
* stale recommendation instead of confusing deletion with transport loss.
|
||||
*/
|
||||
public List<Map<String, Object>> fetchCurrent(List<Long> contractIds, User user) {
|
||||
if (contractIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
LambdaQueryWrapper<ContractNew> wrapper = new LambdaQueryWrapper<ContractNew>()
|
||||
.in(ContractNew::getId, contractIds);
|
||||
if (user != null) {
|
||||
permissionService.applyContractRowFilter(wrapper, user);
|
||||
}
|
||||
List<ContractNew> contracts = contractNewMapper.selectList(wrapper);
|
||||
List<Long> propertyIds = contracts.stream().map(ContractNew::getPropertyId)
|
||||
.filter(Objects::nonNull).distinct().collect(Collectors.toList());
|
||||
Map<Long, String> customerIds = propertyIds.isEmpty() ? Map.of() : customerIdsByProperty(propertyIds);
|
||||
Map<Long, Integer> inputOrder = new HashMap<>();
|
||||
for (int index = 0; index < contractIds.size(); index++) {
|
||||
inputOrder.put(contractIds.get(index), index);
|
||||
}
|
||||
contracts.sort(Comparator.comparingInt(row -> inputOrder.getOrDefault(row.getId(), Integer.MAX_VALUE)));
|
||||
List<Map<String, Object>> items = new ArrayList<>(contracts.size());
|
||||
for (ContractNew contract : contracts) {
|
||||
items.add(toItem(contract, ChangesEngine.effectiveTs(contract.getModifyTime(), contract.getCreateTime()),
|
||||
customerIds));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private Map<String, Object> toItem(ContractNew row, long mt, Map<Long, String> customerIds) {
|
||||
boolean deleted = row.getSoftDelete() != null && row.getSoftDelete() == 1;
|
||||
Date effective = row.getModifyTime() != null ? row.getModifyTime() : row.getCreateTime();
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import me.iiv.agent.mcp.ToolObject;
|
||||
import me.iiv.agent.mcp.McpErrorCode;
|
||||
import me.iiv.agent.mcp.ToolCallException;
|
||||
import me.iiv.agent.mcp.XqTool;
|
||||
import me.iiv.agent.mcp.sync.ChangesEngine;
|
||||
import me.iiv.agent.mcp.sync.ContractsSyncEntity;
|
||||
import me.iiv.agent.mcp.sync.PropertiesSyncEntity;
|
||||
import me.iiv.agent.mcp.sync.UsersSyncEntity;
|
||||
import me.iiv.agent.security.AuthContext;
|
||||
import me.iiv.agent.security.AuthContextHolder;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -35,9 +45,11 @@ public class ChangesTools implements ToolObject {
|
||||
private static final String LIMIT_DESC = "每页条数,默认 100,上限 200(超出截断为 200 并在 notice 提示)";
|
||||
|
||||
final ChangesEngine changesEngine;
|
||||
final ContractsSyncEntity contractsSyncEntity;
|
||||
|
||||
public ChangesTools(ChangesEngine changesEngine) {
|
||||
public ChangesTools(ChangesEngine changesEngine, ContractsSyncEntity contractsSyncEntity) {
|
||||
this.changesEngine = changesEngine;
|
||||
this.contractsSyncEntity = contractsSyncEntity;
|
||||
}
|
||||
|
||||
@XqTool(category = "sync", kind = "read")
|
||||
@@ -75,4 +87,51 @@ public class ChangesTools implements ToolObject {
|
||||
@ToolParam(required = false, description = LIMIT_DESC) Integer limit) {
|
||||
return changesEngine.execute(ContractsSyncEntity.NAME, syncToken, cursor, limit);
|
||||
}
|
||||
|
||||
@XqTool(category = "sync", kind = "read")
|
||||
@Tool(name = "contracts.current.batch_get", description = "发送经营提醒前批量复核合同当前权威状态。"
|
||||
+ "仅接受合同稳定ID列表,最多200个;返回items及not_found_ids。"
|
||||
+ "items字段与contracts.changes.list一致,包含当前状态、到期日、负责人、客户、提醒配置、版本和删除标记。"
|
||||
+ "数据域由Token确定,调用方不得传tenant_id或scope。")
|
||||
public Map<String, Object> contractsCurrentBatchGet(
|
||||
@ToolParam(description = "合同稳定ID列表,1至200个;重复ID自动去重并保持首次出现顺序")
|
||||
List<String> contractIds) {
|
||||
List<Long> ids = parseContractIds(contractIds);
|
||||
AuthContext context = AuthContextHolder.get();
|
||||
List<Map<String, Object>> items = contractsSyncEntity.fetchCurrent(ids,
|
||||
context == null ? null : context.getUser());
|
||||
LinkedHashSet<String> found = new LinkedHashSet<>();
|
||||
for (Map<String, Object> item : items) {
|
||||
found.add(String.valueOf(item.get("contract_id")));
|
||||
}
|
||||
List<String> missing = ids.stream().map(String::valueOf).filter(id -> !found.contains(id)).toList();
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("checked_at", OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
|
||||
result.put("count", items.size());
|
||||
result.put("items", items);
|
||||
result.put("not_found_ids", missing);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<Long> parseContractIds(List<String> contractIds) {
|
||||
if (contractIds == null || contractIds.isEmpty()) {
|
||||
throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, "contractIds必须包含1至200个合同ID");
|
||||
}
|
||||
if (contractIds.size() > 200) {
|
||||
throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, "contractIds最多允许200个合同ID");
|
||||
}
|
||||
LinkedHashSet<Long> unique = new LinkedHashSet<>();
|
||||
for (String raw : contractIds) {
|
||||
try {
|
||||
long id = Long.parseLong(raw == null ? "" : raw.trim());
|
||||
if (id <= 0) {
|
||||
throw new NumberFormatException();
|
||||
}
|
||||
unique.add(id);
|
||||
} catch (NumberFormatException error) {
|
||||
throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, "contractIds包含无效合同ID");
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(unique);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,21 @@ class GuardedToolCallbackTest {
|
||||
verify(delegate, org.mockito.Mockito.never()).call(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentBatchRejectsCallerSuppliedTenant() throws Exception {
|
||||
toolDefinition = DefaultToolDefinition.builder()
|
||||
.name("contracts.current.batch_get").description("d").inputSchema("{}").build();
|
||||
when(delegate.getToolDefinition()).thenReturn(toolDefinition);
|
||||
callback = new GuardedToolCallback(delegate, toolGuard, auditService, rateLimitService);
|
||||
when(toolGuard.isAllowed(42L, "contracts.current.batch_get")).thenReturn(true);
|
||||
|
||||
ToolCallException error = assertThrows(ToolCallException.class,
|
||||
() -> callback.call("{\"contractIds\":[\"1\"],\"tenantId\":\"other\"}"));
|
||||
|
||||
assertEquals(McpErrorCode.PERMISSION_DENIED, error.getCode());
|
||||
verify(delegate, org.mockito.Mockito.never()).call(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncToolWithoutForbiddenParamsPassesThrough() {
|
||||
toolDefinition = DefaultToolDefinition.builder()
|
||||
|
||||
@@ -21,6 +21,8 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -28,6 +30,12 @@ import static org.mockito.Mockito.when;
|
||||
@SuppressWarnings("unchecked")
|
||||
class IntegrationContextServiceTest {
|
||||
|
||||
@Test
|
||||
void currentBatchUsesSyncDataToolConvention() {
|
||||
assertTrue(IntegrationContextService.isSyncDataTool("contracts.current.batch_get"));
|
||||
assertFalse(IntegrationContextService.isSyncDataTool("contract.detail.query"));
|
||||
}
|
||||
|
||||
@Mock
|
||||
TokenScopeMapper tokenScopeMapper;
|
||||
@Mock
|
||||
|
||||
@@ -62,13 +62,15 @@ class RateLimitServiceTest {
|
||||
when(valueOperations.increment(anyString())).thenReturn(1L);
|
||||
|
||||
service.check(42L, "users.changes.list");
|
||||
service.check(42L, "contracts.current.batch_get");
|
||||
service.check(42L, "contract.list.query");
|
||||
|
||||
ArgumentCaptor<String> keys = ArgumentCaptor.forClass(String.class);
|
||||
verify(valueOperations, org.mockito.Mockito.times(2)).increment(keys.capture());
|
||||
verify(valueOperations, org.mockito.Mockito.times(3)).increment(keys.capture());
|
||||
assertTrue(keys.getAllValues().get(0).contains(":sync:"), "changes 工具走 SYNC 档位");
|
||||
assertTrue(keys.getAllValues().get(1).contains(":query:"), "查询工具走 QUERY 档位");
|
||||
assertTrue(!keys.getAllValues().get(0).equals(keys.getAllValues().get(1)), "两档位互不占配额");
|
||||
assertTrue(keys.getAllValues().get(1).contains(":sync:"), "当前状态复核走 SYNC 档位");
|
||||
assertTrue(keys.getAllValues().get(2).contains(":query:"), "查询工具走 QUERY 档位");
|
||||
assertTrue(!keys.getAllValues().get(0).equals(keys.getAllValues().get(2)), "两档位互不占配额");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -226,4 +226,27 @@ class SyncEntitiesTest {
|
||||
|
||||
assertEquals(McpErrorCode.DATA_INTEGRITY_ERROR, error.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void contractsCurrentBatchKeepsInputOrderAndDeletedRows() {
|
||||
ContractsSyncEntity entity = new ContractsSyncEntity(contractNewMapper, properytMapper, permissionService);
|
||||
ContractNew second = new ContractNew();
|
||||
second.setId(10L);
|
||||
second.setContractStatus(2);
|
||||
second.setSoftDelete(0);
|
||||
second.setModifyTime(Timestamp.valueOf("2026-09-15 10:00:00"));
|
||||
ContractNew first = new ContractNew();
|
||||
first.setId(9L);
|
||||
first.setContractStatus(4);
|
||||
first.setSoftDelete(1);
|
||||
first.setModifyTime(Timestamp.valueOf("2026-09-15 09:00:00"));
|
||||
when(contractNewMapper.selectList(any())).thenReturn(new java.util.ArrayList<>(List.of(second, first)));
|
||||
|
||||
List<java.util.Map<String, Object>> items = entity.fetchCurrent(List.of(9L, 10L), null);
|
||||
|
||||
assertEquals(List.of("9", "10"), items.stream().map(item -> item.get("contract_id")).toList());
|
||||
assertEquals("TERMINATED", items.get(0).get("status"));
|
||||
assertEquals("2026-09-15T09:00:00+08:00", items.get(0).get("deleted_at"));
|
||||
verify(permissionService, never()).applyContractRowFilter(any(), any());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import me.iiv.agent.mcp.McpErrorCode;
|
||||
import me.iiv.agent.mcp.ToolCallException;
|
||||
import me.iiv.agent.mcp.sync.ChangesEngine;
|
||||
import me.iiv.agent.mcp.sync.ContractsSyncEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
@@ -8,13 +11,16 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 三个 changes 工具的注册与路由(REQ-20260911-0013)。
|
||||
@@ -24,16 +30,18 @@ class ChangesToolsTest {
|
||||
|
||||
@Mock
|
||||
ChangesEngine changesEngine;
|
||||
@Mock
|
||||
ContractsSyncEntity contractsSyncEntity;
|
||||
|
||||
@Test
|
||||
void springAiPublishesThreeChangesToolSchemas() {
|
||||
ChangesTools tools = new ChangesTools(changesEngine);
|
||||
void springAiPublishesSyncToolSchemas() {
|
||||
ChangesTools tools = new ChangesTools(changesEngine, contractsSyncEntity);
|
||||
var callbacks = MethodToolCallbackProvider.builder().toolObjects(tools).build().getToolCallbacks();
|
||||
Map<String, String> schemas = Arrays.stream(callbacks).collect(Collectors.toMap(
|
||||
callback -> callback.getToolDefinition().name(),
|
||||
callback -> callback.getToolDefinition().inputSchema()));
|
||||
|
||||
assertEquals(3, schemas.size());
|
||||
assertEquals(4, schemas.size());
|
||||
for (String name : new String[]{"users.changes.list", "properties.changes.list", "contracts.changes.list"}) {
|
||||
assertTrue(schemas.containsKey(name), "缺少工具 " + name);
|
||||
String schema = schemas.get(name);
|
||||
@@ -43,11 +51,14 @@ class ChangesToolsTest {
|
||||
assertFalse(schema.contains("tenant_id"), "SYNC 工具禁止 tenant_id 入参: " + name);
|
||||
assertFalse(schema.contains("\"scope\""), "SYNC 工具禁止 scope 入参: " + name);
|
||||
}
|
||||
assertTrue(schemas.containsKey("contracts.current.batch_get"));
|
||||
assertTrue(schemas.get("contracts.current.batch_get").contains("contractIds"));
|
||||
assertFalse(schemas.get("contracts.current.batch_get").contains("tenant_id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolsRouteToEngineWithEntityName() {
|
||||
ChangesTools tools = new ChangesTools(changesEngine);
|
||||
ChangesTools tools = new ChangesTools(changesEngine, contractsSyncEntity);
|
||||
|
||||
tools.usersChangesList("t1", "c1", 50);
|
||||
verify(changesEngine).execute("users", "t1", "c1", 50);
|
||||
@@ -58,4 +69,31 @@ class ChangesToolsTest {
|
||||
tools.contractsChangesList("t2", null, 200);
|
||||
verify(changesEngine).execute("contracts", "t2", null, 200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentBatchNormalizesIdsAndReportsMissingRows() {
|
||||
ChangesTools tools = new ChangesTools(changesEngine, contractsSyncEntity);
|
||||
when(contractsSyncEntity.fetchCurrent(List.of(9L, 10L), null))
|
||||
.thenReturn(List.of(Map.of("contract_id", "9")));
|
||||
|
||||
Map<String, Object> result = tools.contractsCurrentBatchGet(List.of("9", "10", "9"));
|
||||
|
||||
assertEquals(1, result.get("count"));
|
||||
assertEquals(List.of("10"), result.get("not_found_ids"));
|
||||
verify(contractsSyncEntity).fetchCurrent(List.of(9L, 10L), null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentBatchRejectsInvalidOrOversizedIds() {
|
||||
ChangesTools tools = new ChangesTools(changesEngine, contractsSyncEntity);
|
||||
ToolCallException invalid = assertThrows(ToolCallException.class,
|
||||
() -> tools.contractsCurrentBatchGet(List.of("not-an-id")));
|
||||
assertEquals(McpErrorCode.INVALID_ARGUMENT, invalid.getCode());
|
||||
|
||||
List<String> tooMany = java.util.stream.IntStream.rangeClosed(1, 201)
|
||||
.mapToObj(String::valueOf).toList();
|
||||
ToolCallException oversized = assertThrows(ToolCallException.class,
|
||||
() -> tools.contractsCurrentBatchGet(tooMany));
|
||||
assertEquals(McpErrorCode.INVALID_ARGUMENT, oversized.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user