diff --git a/xiaoqu-agent-service/doc/crm-integration-guide.md b/xiaoqu-agent-service/doc/crm-integration-guide.md index 5ab885d..b51e43c 100644 --- a/xiaoqu-agent-service/doc/crm-integration-guide.md +++ b/xiaoqu-agent-service/doc/crm-integration-guide.md @@ -3,6 +3,8 @@ > 适用版本:REQ-20260911-0013(增量同步框架)交付后 > 端点:`https://agent.ququclean.com/mcp`(生产)/ `http://192.168.4.236:8088/mcp`(内网) > 协议:MCP Streamable HTTP(JSON-RPC),所有响应为 SSE,取 `data:` 行 +> +> 当前生产可用性、阻塞项和下一步以[对接进度](crm-sync-integration-progress.md)为准;本手册描述目标调用方式,不代表发布门禁已经通过。 ## 0. 边界(先读这段) @@ -43,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): @@ -87,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) ``` @@ -105,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 推导 | diff --git a/xiaoqu-agent-service/doc/crm-sync-integration-progress.md b/xiaoqu-agent-service/doc/crm-sync-integration-progress.md new file mode 100644 index 0000000..42b5c2f --- /dev/null +++ b/xiaoqu-agent-service/doc/crm-sync-integration-progress.md @@ -0,0 +1,140 @@ +# SaaS MCP → 小趣 AI 经营系统对接进度 + +> MCP 系统独立进度入口。最后核对:2026-09-15;代码位于`codex/crm-contract-sync-review`本地评审分支。 +> +> 本文只记录 `xiaoqu_mcp_service` 的责任和状态。小趣 AI / CRM 的规则、事项、AI、页面与推送进度,以其仓库 `PROJECT.md` 为准。 + +## 1. 两个系统的边界 + +| 系统 | 必须负责 | 明确不负责 | +|---|---|---| +| SaaS MCP | 服务身份、源租户/数据域、工具授权、源系统行级权限、用户/物业/合同标准变化流、稳定游标、审计与标准错误 | 到期规则、经营优先级、AI决策、经营事项状态机、销售页面、APNs推送 | +| 小趣 AI / CRM | 租户连接、数据镜像、身份与客户映射、确定性规则、仅把命中数据交给AI、事项生成/流转、展示、操作、反馈、推送前复核 | 猜测源租户、绕过MCP权限、兼容不符合冻结Schema的错误字段 | + +双方唯一连接面是版本化接口契约:`integration.context.get`、三条 `*.changes.list`、`contracts.current.batch_get`、统一错误信封,以及真实端到端验收。任何一侧内部实现都不能成为另一侧的隐式依赖。 + +## 2. 当前结论 + +当前状态:**协议可连接,公开字段Schema已在本地修正并通过回归;可靠同步与服务身份仍未通过,不能用于真实销售合同到期推送。** + +交付状态:修正位于独立分支`codex/crm-contract-sync-review`,本地提交已形成;当前执行环境没有`wangheng/xiaoqu_mcp_service`写权限,尚未推送、合并或部署,生产服务仍是旧行为。 + +已经具备: + +- MCP Streamable HTTP 会话、SSE响应和Schema v2信封。 +- `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侧不得通过放宽校验来掩盖这些问题。 + +## 3. 已确认问题与处理顺序 + +### P0-1 服务身份与源数据域语义混淆 + +代码核对结论:当前SaaS业务库没有统一的`tenant_id`字段。人员、物业、合同处于同一个业务数据域,在线行权限由代理商、物业和销售关系计算;合同负责人由`contract_new.sale_id`表达。不能为了对接新增一个虚假的行级租户字段。 + +现状仍有两个独立问题:`integration.context.get.tenant_id`由作用域编码拼接得到,`sync`被误当成数据域;服务Token又按`serviceName + scope`全局复用,创建它的Open Client身份没有进入Token。作用域只代表“能用什么工具”,不是数据来源或调用方身份。 + +影响:消费者无法稳定识别数据来自哪套SaaS实例;不同Open Client使用相同`serviceName`时可能复用服务Token。它不会替代合同负责人分发,但会影响连接隔离、吊销和审计。 + +标准改造: + +1. 为每套MCP部署配置稳定的`source_domain_id`,例如生产SaaS数据域标识;它来自服务端配置,不由请求体、scope或名称推断。 +2. 服务Token持久化`open_client_id + subject_name`,ensure按调用方、服务主体和scope组合幂等签发;不同Open Client不共享Token。 +3. `integration.context.get`返回稳定`source_domain_id`。若为保持既有契约继续输出`tenant_id`,必须明确该字段在当前单数据域SaaS中表示“源数据域”,不能再返回`sync`。 +4. SYNC服务Token读取该单一数据域的完整变化流;记录按合同`owner_user_id`分发。在线个人查询继续使用现有人员ACL,两条通道不混用。 +5. 小趣AI的租户连接负责把自己的`tenant_id`显式绑定到该`source_domain_id`,禁止同一个源数据域被误配给多个目标租户。 + +验收:两个Open Client即使使用相同`serviceName=crm`也得到不同服务Token;吊销其中一个不影响另一个;上下文返回稳定源数据域;小趣AI连接错配源数据域时在同步前失败关闭。 + +### P0-2 三条变化流公开字段Schema(本地已修正,待部署联调) + +原实现与冻结契约的差异: + +| 流 | 当前输出 | 必须输出 | +|---|---|---| +| users | `mobile`;数值型`user_id/organization_ids` | `verified_mobile`;字符串型稳定ID;状态使用公开枚举 | +| properties | 数值型`property_id` | 字符串型稳定ID;状态使用公开枚举 | +| contracts | 数值型`contract_id/property_id/owner_user_id`、数值状态`0..4`、数值`expire_remind`,缺`remind_days` | 字符串型稳定ID、`PENDING/REJECTED/ACTIVE/EXPIRED/TERMINATED/VOID`、布尔提醒标记、实际提醒天数 | + +已完成:新增独立公开DTO,将三类稳定ID统一为字符串;`mobile`改为`verified_mobile`;合同状态转换为公开枚举;`expire_remind`转换为布尔值;补齐`remind_days`;变化页使用统一`stream`字段。未知合同状态按`DATA_INTEGRITY_ERROR`失败关闭,不伪造业务语义。 + +验证:合并最新主分支后,Java编译、管理前端构建及MCP服务全量`229`项测试通过,`0`失败、`0`错误。尚需部署后的真实数据样例和小趣Gateway严格消费者契约联调,完成前仍不算生产通过。 + +验收:用真实样例对三条流执行消费者契约测试;Gateway无需兼容代码即可全部通过,未知字段、未知状态和错误类型必须失败。 + +### P0-3 当前“快照”不能保证重放稳定 + +现状:令牌只保存`cutoff`,每页仍直接查询持续变化的业务表。分页期间,`cutoff`之前的记录被修改、删除或权限变化时,相同`sync_token + cursor + limit`可能得到不同结果;秒级`modify_time`也可能漏掉同一行在边界秒内的再次修改。 + +影响:合同规模扩大或同步期间发生业务写入时,存在漏数、重复、记录消失和重放不一致风险,达不到文档声称的“不重不漏”。 + +标准改造:建立通用变更日志/Outbox(每次变更拥有单调递增`change_seq`,保存实体ID、操作类型、版本和必要投影),同步窗口按`change_seq`冻结;游标按`change_seq`推进。若使用CDC,也必须落成可保留、可重放的变化流,不能直接分页扫活表冒充快照。 + +验收:同步中并发更新、删除、负责人变更、权限变更以及相同请求重放时,旧快照结果不变,新变化只进入下一轮;压测覆盖成千上万合同。 + +### P0-4 交接Token错误复用了24小时快照TTL + +现状:轮内快照Token、页游标和已完成轮的`next_sync_token`统一执行24小时TTL。 + +影响:每日任务只要稍晚、停机或积压,就可能被迫全量重建;数据量增大后会形成周期性全量压力。 + +标准改造:只给轮内快照/游标设置短TTL;交接Token按变更日志保留期校验,或保存持久化消费位点。过期时返回可观测的最早可用位置及标准错误,消费者执行受控重建。 + +验收:间隔超过24小时仍可正常增量;只有位点早于服务端保留期才触发重建。 + +### P0-5 数据权限版本不能覆盖真实权限变化 + +现状:`data_scope_version`仅取`token_scope`关系的修改时间;Open Client身份关联、scope-tool变化、源数据域ACL变化不会稳定反映。 + +影响:权限变化后旧同步位点可能继续使用,消费者无法判断需要重建或停止派发。 + +标准改造:维护数据域授权版本,任何调用方身份关联、工具授权、行级ACL语义变更都原子递增;上下文与同步Token使用同一个版本。 + +验收:修改任一影响可见数据的授权后,旧轮立即失败关闭,新轮在新数据域版本下重建。 + +### P0-6 发送前权威复核(本地已实现,待部署联调) + +已实现`contracts.current.batch_get`:一次读取1—200份合同当前事实,按输入顺序返回已找到记录,并明确返回`not_found_ids`;软删记录不会伪装成“查无数据”。该工具沿用SYNC服务身份,禁止调用方覆盖数据域,使用SYNC独立限流档并进入统一审计。 + +小趣AI侧已经按同一契约实现短时发送凭证:只有状态、到期日、负责人、客户归属、提醒字段及续约结果与本地候选一致才允许R04进入推送;缺失、删除、字段变化、响应不完整或MCP不可用均失败关闭,R03不受影响。 + +尚未完成:本分支未推送和部署;生产`sync`作用域还必须绑定该新工具;需要用真实合同验证负责人变更、删除、到期日修改和服务不可用四类反向场景。 + +## 4. 依赖顺序 + +1. 冻结服务端`source_domain_id`配置、单数据域范围和合同状态/提醒字段字典。 +2. 完成Open Client与服务Token身份关联,补迁移、ensure和上下文测试。 +3. 建立通用变更日志/Outbox与保留策略,再迁移三条变化流;不继续强化活表分页方案。 +4. 使用独立公开DTO完成三条流字段映射和消费者契约测试。 +5. 修正交接Token生命周期与统一数据权限版本。 +6. 在测试环境用真实用户、物业、合同及变更/删除数据完成MCP侧验收。 +7. 交付Open Client、稳定源数据域ID和凭证给小趣AI侧,联合验证“同步→映射→规则→事项→展示→推送前复核”。 + +## 5. 状态看板 + +| 工作项 | 状态 | 责任系统 | 下一步 | +|---|---|---|---| +| MCP协议与工具发现 | 已完成 | MCP | 保持兼容并纳入回归 | +| 服务密钥与Token基础生命周期 | 已完成 | MCP | 在租户模型完成后升级幂等键 | +| 源数据域标识与服务主体隔离 | 未完成,P0阻塞 | MCP | 增加稳定部署数据域标识和Open Client关联 | +| 三流公开字段Schema | 本地代码完成,待部署联调 | MCP | 部署后用真实三流样例跑Gateway严格契约测试 | +| 发送前合同当前状态批量复核 | 本地代码完成,待部署联调 | MCP + 小趣AI | 绑定sync作用域并完成四类真实反向用例 | +| 可靠变化日志与稳定快照 | 未完成,P0阻塞 | MCP | 设计Outbox/CDC及保留策略 | +| 权限版本与交接位点生命周期 | 未完成,P0阻塞 | MCP | 与租户/变化日志模型一起实现 | +| 小趣AI连接与消费代码 | 已完成,待部署联调 | 小趣AI | 见小趣AI仓库`PROJECT.md` | +| 真实销售合同到期推送 | 未开放 | 双方联合验收 | 所有P0通过后才允许从Shadow转Live | + +## 6. 发布门禁 + +以下条件全部满足前,不得把MCP同步标记为生产可用,也不得向真实销售发送合同提醒: + +- 稳定源数据域、服务主体隔离和连接错配反向用例通过。 +- 三条变化流通过冻结Schema及真实数据样例校验。 +- 并发写入、删除、断点续传、超过24小时续传和权限变化测试通过。 +- 凭证不入库明文、不入日志;轮换和吊销测试通过。 +- 小趣AI侧完成负责人、客户双映射和发送前实时复核。 +- 完整链路具有`request_id/snapshot_id/source_domain_id`审计证据,可定位每条事项和推送的数据来源。 diff --git a/xiaoqu-agent-service/sql/seed-dev.sql b/xiaoqu-agent-service/sql/seed-dev.sql index 4585a79..7d18337 100644 --- a/xiaoqu-agent-service/sql/seed-dev.sql +++ b/xiaoqu-agent-service/sql/seed-dev.sql @@ -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; diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/GuardedToolCallback.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/GuardedToolCallback.java index 9496075..f667d4f 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/GuardedToolCallback.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/GuardedToolCallback.java @@ -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 { diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/IntegrationContextService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/IntegrationContextService.java index 03a230a..aacee72 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/IntegrationContextService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/IntegrationContextService.java @@ -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 capabilityLevels(Collection allowedTools) { List 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; diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/McpErrorCode.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/McpErrorCode.java index b2aa88f..43c0d18 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/McpErrorCode.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/McpErrorCode.java @@ -28,6 +28,9 @@ public enum McpErrorCode { /** 调用方声明的 schema_version 不受支持 */ SCHEMA_VERSION_UNSUPPORTED, + /** 工具入参格式、数量或取值不符合公开契约 */ + INVALID_ARGUMENT, + /** 数据处理过程中发现不一致或未预期异常(兜底码,内部细节只落服务端日志) */ DATA_INTEGRITY_ERROR } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/RateLimitService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/RateLimitService.java index 3773aff..dc3823c 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/RateLimitService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/RateLimitService.java @@ -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; diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/ChangesEngine.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/ChangesEngine.java index 09862f1..19c3247 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/ChangesEngine.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/ChangesEngine.java @@ -121,7 +121,7 @@ public class ChangesEngine { // 4. 组装响应:中间页回传锚点 + next_cursor;最后一页签发 next_sync_token String roundToken = codec.encodeRound(entityName, fromTs, cutoffTs, snapshotId, scopeVersion); Map result = new LinkedHashMap<>(); - result.put("entity", entityName); + result.put("stream", entityName); result.put("snapshot_id", snapshotId); result.put("data_cutoff_at", iso(new Date(cutoffTs * 1000))); result.put("limit", pageSize); diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/ContractsSyncEntity.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/ContractsSyncEntity.java index 597934e..c1b6920 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/ContractsSyncEntity.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/ContractsSyncEntity.java @@ -6,12 +6,16 @@ import me.iiv.agent.entity.m0.User; import me.iiv.agent.entity.m2.ContractNew; import me.iiv.agent.mapper.m0.ProperytMapper; import me.iiv.agent.mapper.m2.ContractNewMapper; +import me.iiv.agent.mcp.McpErrorCode; +import me.iiv.agent.mcp.ToolCallException; import me.iiv.agent.permission.PermissionService; 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; @@ -74,25 +78,48 @@ 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> fetchCurrent(List contractIds, User user) { + if (contractIds.isEmpty()) { + return List.of(); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .in(ContractNew::getId, contractIds); + if (user != null) { + permissionService.applyContractRowFilter(wrapper, user); + } + List contracts = contractNewMapper.selectList(wrapper); + List propertyIds = contracts.stream().map(ContractNew::getPropertyId) + .filter(Objects::nonNull).distinct().collect(Collectors.toList()); + Map customerIds = propertyIds.isEmpty() ? Map.of() : customerIdsByProperty(propertyIds); + Map 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> items = new ArrayList<>(contracts.size()); + for (ContractNew contract : contracts) { + items.add(toItem(contract, ChangesEngine.effectiveTs(contract.getModifyTime(), contract.getCreateTime()), + customerIds)); + } + return items; + } + private Map toItem(ContractNew row, long mt, Map customerIds) { boolean deleted = row.getSoftDelete() != null && row.getSoftDelete() == 1; Date effective = row.getModifyTime() != null ? row.getModifyTime() : row.getCreateTime(); - Map item = new LinkedHashMap<>(); - item.put("contract_id", row.getId()); - item.put("contract_no", row.getContractNo()); - item.put("contract_name", row.getContractName()); - item.put("status", row.getContractStatus()); - item.put("start_at", ChangesEngine.iso(row.getStartDate())); - item.put("end_at", ChangesEngine.iso(row.getEndDate())); - item.put("amount_minor", toMinor(row.getAmount())); - item.put("customer_id", row.getPropertyId() == null ? null : customerIds.get(row.getPropertyId())); - item.put("property_id", row.getPropertyId()); - item.put("owner_user_id", row.getSaleId()); - item.put("expire_remind", row.getExpireRemind()); - item.put("updated_at", ChangesEngine.iso(effective)); - item.put("version", mt); - item.put("deleted_at", deleted ? ChangesEngine.iso(effective) : null); - return item; + return SyncChangeSchemas.toMap(new SyncChangeSchemas.ContractChange( + stableId(row.getId()), row.getContractNo(), row.getContractName(), + publicStatus(row.getContractStatus()), ChangesEngine.iso(row.getStartDate()), + ChangesEngine.iso(row.getEndDate()), toMinor(row.getAmount()), + row.getPropertyId() == null ? null : customerIds.get(row.getPropertyId()), + stableId(row.getPropertyId()), stableId(row.getSaleId()), + Integer.valueOf(1).equals(row.getExpireRemind()), row.getRemindDays(), + ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null)); } /** 元 → 分(amount 为 decimal(14,2),精确换算);null 原样返回 null。 */ @@ -100,6 +127,30 @@ public class ContractsSyncEntity extends AbstractSyncEntity implements SyncEntit return amount == null ? null : amount.movePointRight(2).longValue(); } + /** Legacy database status code to the stable public contract enum. */ + static String publicStatus(Integer status) { + if (status == null) { + throw invalidStatus(null); + } + return switch (status) { + case 0 -> "PENDING"; + case 1 -> "REJECTED"; + case 2 -> "ACTIVE"; + case 3 -> "EXPIRED"; + case 4 -> "TERMINATED"; + default -> throw invalidStatus(status); + }; + } + + private static ToolCallException invalidStatus(Integer status) { + return new ToolCallException(McpErrorCode.DATA_INTEGRITY_ERROR, + "合同状态无法映射到公开契约: " + status); + } + + private static String stableId(Long id) { + return id == null ? null : id.toString(); + } + /** * property_id → customerId 映射。properyt.customerId 存在 NULL 行(2026-09-14 dev 冒烟命中), * Collectors.toMap 遇 null 值抛 NPE,必须用 put 逐条装填。 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/PropertiesSyncEntity.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/PropertiesSyncEntity.java index 275b547..63cd032 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/PropertiesSyncEntity.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/PropertiesSyncEntity.java @@ -9,7 +9,6 @@ import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.Date; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -59,17 +58,13 @@ public class PropertiesSyncEntity extends AbstractSyncEntity implements SyncEnti private Map toItem(Properyt row, long mt) { boolean deleted = row.getSoftDelete() != null && row.getSoftDelete() == 1; Date effective = row.getModifyTime() != null ? row.getModifyTime() : row.getCreateTime(); - Map item = new LinkedHashMap<>(); - item.put("property_id", row.getId()); - item.put("customer_id", row.getCustomerId()); - item.put("property_name", row.getName()); - item.put("customer_name", row.getJinideName()); - item.put("unified_social_credit_code", row.getIdentificationNumber()); - item.put("external_customer_no", row.getSerialnumber()); - item.put("status", deleted ? "inactive" : "active"); - item.put("updated_at", ChangesEngine.iso(effective)); - item.put("version", mt); - item.put("deleted_at", deleted ? ChangesEngine.iso(effective) : null); - return item; + return SyncChangeSchemas.toMap(new SyncChangeSchemas.PropertyChange( + stableId(row.getId()), row.getCustomerId(), row.getName(), row.getJinideName(), + row.getIdentificationNumber(), row.getSerialnumber(), deleted ? "INACTIVE" : "ACTIVE", + ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null)); + } + + private static String stableId(Long id) { + return id == null ? null : id.toString(); } } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/SyncChangeSchemas.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/SyncChangeSchemas.java new file mode 100644 index 0000000..62235de --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/SyncChangeSchemas.java @@ -0,0 +1,73 @@ +package me.iiv.agent.mcp.sync; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.List; +import java.util.Map; + +/** + * Public schemas for the three change streams. + * + *

The source tables are legacy persistence models with numeric identifiers, + * integer flags and database-specific status codes. These DTOs form the stable + * MCP boundary so persistence details cannot leak into consumers.

+ */ +final class SyncChangeSchemas { + + private static final ObjectMapper MAPPER = new ObjectMapper() + .setSerializationInclusion(JsonInclude.Include.NON_NULL); + private static final TypeReference> MAP_TYPE = new TypeReference<>() { }; + + private SyncChangeSchemas() { + } + + static Map toMap(Object value) { + return MAPPER.convertValue(value, MAP_TYPE); + } + + record UserChange( + @JsonProperty("user_id") String userId, + @JsonProperty("display_name") String displayName, + String status, + @JsonProperty("employee_no") String employeeNo, + @JsonProperty("verified_mobile") String verifiedMobile, + @JsonProperty("organization_ids") List organizationIds, + @JsonProperty("updated_at") String updatedAt, + long version, + @JsonProperty("deleted_at") String deletedAt) { + } + + record PropertyChange( + @JsonProperty("property_id") String propertyId, + @JsonProperty("customer_id") String customerId, + @JsonProperty("property_name") String propertyName, + @JsonProperty("customer_name") String customerName, + @JsonProperty("unified_social_credit_code") String unifiedSocialCreditCode, + @JsonProperty("external_customer_no") String externalCustomerNo, + String status, + @JsonProperty("updated_at") String updatedAt, + long version, + @JsonProperty("deleted_at") String deletedAt) { + } + + record ContractChange( + @JsonProperty("contract_id") String contractId, + @JsonProperty("contract_no") String contractNo, + @JsonProperty("contract_name") String contractName, + String status, + @JsonProperty("start_at") String startAt, + @JsonProperty("end_at") String endAt, + @JsonProperty("amount_minor") Long amountMinor, + @JsonProperty("customer_id") String customerId, + @JsonProperty("property_id") String propertyId, + @JsonProperty("owner_user_id") String ownerUserId, + @JsonProperty("expire_remind") boolean expireRemind, + @JsonProperty("remind_days") Integer remindDays, + @JsonProperty("updated_at") String updatedAt, + long version, + @JsonProperty("deleted_at") String deletedAt) { + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/UsersSyncEntity.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/UsersSyncEntity.java index 3cf4943..b87649d 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/UsersSyncEntity.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/UsersSyncEntity.java @@ -7,14 +7,13 @@ import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.Date; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * users 同步实体(m0.user):人员主数据增量。 * 字段口径(2026-09-14 字段核对):status 对齐 soft_delete(在职状态不用 work_status,那是考勤); - * mobile 即唯一手机号字段(verified_mobile 无对应列,按决策砍掉);organization_ids 取 dept_id; + * verified_mobile 映射唯一手机号字段 mobile;organization_ids 取 dept_id; * 无人员合并机制,merged_into_user_id 省略。users 表无行级过滤先例,SYNC 工具以 scope 白名单为闸。 */ @Component @@ -50,16 +49,13 @@ public class UsersSyncEntity extends AbstractSyncEntity implements SyncEntity { private Map toItem(User row, long mt) { boolean deleted = row.getSoftDelete() != null && row.getSoftDelete() == 1; Date effective = row.getModifyTime() != null ? row.getModifyTime() : row.getCreateTime(); - Map item = new LinkedHashMap<>(); - item.put("user_id", row.getId()); - item.put("display_name", row.getName()); - item.put("status", deleted ? "inactive" : "active"); - item.put("employee_no", row.getNo()); - item.put("mobile", row.getMobile()); - item.put("organization_ids", row.getDeptId() == null ? List.of() : List.of(row.getDeptId())); - item.put("updated_at", ChangesEngine.iso(effective)); - item.put("version", mt); - item.put("deleted_at", deleted ? ChangesEngine.iso(effective) : null); - return item; + return SyncChangeSchemas.toMap(new SyncChangeSchemas.UserChange( + stableId(row.getId()), row.getName(), deleted ? "INACTIVE" : "ACTIVE", row.getNo(), + row.getMobile(), row.getDeptId() == null ? List.of() : List.of(stableId(row.getDeptId())), + ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null)); + } + + private static String stableId(Long id) { + return id == null ? null : id.toString(); } } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ChangesTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ChangesTools.java index e14db26..7028b36 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ChangesTools.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ChangesTools.java @@ -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,15 +45,17 @@ 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") @Tool(name = "users.changes.list", description = "增量同步人员主数据(m0.user)。" + PROTOCOL_DESC + "items 字段:user_id/display_name/status(active=在职 inactive=删除,对齐软删标记)/" - + "employee_no/mobile/organization_ids(部门 id 列表)/updated_at(带时区)/version/deleted_at。") + + "employee_no/verified_mobile/organization_ids(部门 id 列表)/updated_at(带时区)/version/deleted_at。") public Map usersChangesList( @ToolParam(required = false, description = SYNC_TOKEN_DESC) String syncToken, @ToolParam(required = false, description = CURSOR_DESC) String cursor, @@ -65,9 +77,9 @@ public class ChangesTools implements ToolObject { @XqTool(category = "sync", kind = "read") @Tool(name = "contracts.changes.list", description = "增量同步合同主数据(m2.contract_new)。" + PROTOCOL_DESC - + "items 字段:contract_id/contract_no/contract_name/status(0=待审 1=驳回 2=正常 3=过期 4=终止)/" + + "items 字段:contract_id/contract_no/contract_name/status(PENDING/REJECTED/ACTIVE/EXPIRED/TERMINATED)/" + "start_at/end_at(带时区)/amount_minor(金额,分)/customer_id/property_id/" - + "owner_user_id(负责人稳定 user.id)/expire_remind/updated_at/version/deleted_at。" + + "owner_user_id(负责人稳定 user.id)/expire_remind/remind_days/updated_at/version/deleted_at。" + "行级权限跟随 Token 绑定的人(与 contract.list.query 同一 ACL)。") public Map contractsChangesList( @ToolParam(required = false, description = SYNC_TOKEN_DESC) String syncToken, @@ -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 contractsCurrentBatchGet( + @ToolParam(description = "合同稳定ID列表,1至200个;重复ID自动去重并保持首次出现顺序") + List contractIds) { + List ids = parseContractIds(contractIds); + AuthContext context = AuthContextHolder.get(); + List> items = contractsSyncEntity.fetchCurrent(ids, + context == null ? null : context.getUser()); + LinkedHashSet found = new LinkedHashSet<>(); + for (Map item : items) { + found.add(String.valueOf(item.get("contract_id"))); + } + List missing = ids.stream().map(String::valueOf).filter(id -> !found.contains(id)).toList(); + Map 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 parseContractIds(List 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 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); + } } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/GuardedToolCallbackTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/GuardedToolCallbackTest.java index 715dba5..236876a 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/GuardedToolCallbackTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/GuardedToolCallbackTest.java @@ -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() diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/IntegrationContextServiceTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/IntegrationContextServiceTest.java index 59c360d..1f302eb 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/IntegrationContextServiceTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/IntegrationContextServiceTest.java @@ -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 diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/RateLimitServiceTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/RateLimitServiceTest.java index 9e178a5..73e8071 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/RateLimitServiceTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/RateLimitServiceTest.java @@ -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 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 diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/sync/ChangesEngineTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/sync/ChangesEngineTest.java index f403727..89907b7 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/sync/ChangesEngineTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/sync/ChangesEngineTest.java @@ -94,7 +94,7 @@ class ChangesEngineTest { Map result = engine.execute("users", null, null, null); - assertEquals("users", result.get("entity")); + assertEquals("users", result.get("stream")); assertNotNull(result.get("snapshot_id")); assertNotNull(result.get("data_cutoff_at")); assertEquals(2, result.get("count")); diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/sync/SyncEntitiesTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/sync/SyncEntitiesTest.java index ab67251..a044612 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/sync/SyncEntitiesTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/sync/SyncEntitiesTest.java @@ -9,6 +9,8 @@ import me.iiv.agent.entity.m2.ContractNew; import me.iiv.agent.mapper.m0.ProperytMapper; import me.iiv.agent.mapper.m0.UserMapper; import me.iiv.agent.mapper.m2.ContractNewMapper; +import me.iiv.agent.mcp.McpErrorCode; +import me.iiv.agent.mcp.ToolCallException; import me.iiv.agent.permission.PermissionService; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.BeforeEach; @@ -24,6 +26,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; @@ -79,19 +82,19 @@ class SyncEntitiesTest { assertTrue(sql.contains("COALESCE(modify_time, create_time)"), "窗口必须按生效修改时间过滤: " + sql); assertEquals(2, rows.size()); - assertEquals(1L, rows.get(0).item().get("user_id")); + assertEquals("1", rows.get(0).item().get("user_id")); assertEquals("张三", rows.get(0).item().get("display_name")); - assertEquals("active", rows.get(0).item().get("status")); + assertEquals("ACTIVE", rows.get(0).item().get("status")); assertEquals("E001", rows.get(0).item().get("employee_no")); - assertEquals("13800000000", rows.get(0).item().get("mobile")); - assertEquals(List.of(88L), rows.get(0).item().get("organization_ids")); + assertEquals("13800000000", rows.get(0).item().get("verified_mobile")); + assertEquals(List.of("88"), rows.get(0).item().get("organization_ids")); assertEquals("2026-09-01T10:00:00+08:00", rows.get(0).item().get("updated_at")); assertEquals(Timestamp.valueOf("2026-09-01 10:00:00").getTime() / 1000, rows.get(0).item().get("version")); assertNull(rows.get(0).item().get("deleted_at")); // tombstone:软删行 deleted_at 非空、status=inactive - assertEquals("inactive", rows.get(1).item().get("status")); + assertEquals("INACTIVE", rows.get(1).item().get("status")); assertEquals("2026-09-02T10:00:00+08:00", rows.get(1).item().get("deleted_at")); assertEquals(List.of(), rows.get(1).item().get("organization_ids"), "无部门时为空列表"); } @@ -131,13 +134,13 @@ class SyncEntitiesTest { List rows = entity.fetchPage(null, 2000L, null, null, 3, user); verify(permissionService).applyPropertyRowFilter(any(), any()); - assertEquals(9L, rows.get(0).item().get("property_id")); + assertEquals("9", rows.get(0).item().get("property_id")); assertEquals("KD10086", rows.get(0).item().get("customer_id")); assertEquals("望京SOHO", rows.get(0).item().get("property_name")); assertEquals("金蝶客户名称", rows.get(0).item().get("customer_name")); assertEquals("91110105XXXXXXXX", rows.get(0).item().get("unified_social_credit_code")); assertEquals("452542", rows.get(0).item().get("external_customer_no")); - assertEquals("active", rows.get(0).item().get("status")); + assertEquals("ACTIVE", rows.get(0).item().get("status")); } @Test @@ -156,6 +159,7 @@ class SyncEntitiesTest { ContractsSyncEntity entity = new ContractsSyncEntity(contractNewMapper, properytMapper, permissionService); ContractNew contract = new ContractNew(); contract.setId(10L); + contract.setContractStatus(2); contract.setPropertyId(2L); contract.setSoftDelete(0); contract.setModifyTime(Timestamp.valueOf("2026-09-01 13:54:21")); @@ -182,6 +186,7 @@ class SyncEntitiesTest { contract.setPropertyId(1L); contract.setSaleId(1001L); contract.setExpireRemind(1); + contract.setRemindDays(90); contract.setAmount(new BigDecimal("120000.00")); contract.setStartDate(java.sql.Date.valueOf("2026-01-01")); contract.setEndDate(java.sql.Date.valueOf("2026-12-31")); @@ -198,18 +203,50 @@ class SyncEntitiesTest { List rows = entity.fetchPage(null, 2000L, null, null, 3, user); verify(permissionService).applyContractRowFilter(any(), any()); - assertEquals(9L, rows.get(0).item().get("contract_id")); + assertEquals("9", rows.get(0).item().get("contract_id")); assertEquals("452542C001", rows.get(0).item().get("contract_no")); - assertEquals(2, rows.get(0).item().get("status")); + assertEquals("ACTIVE", rows.get(0).item().get("status")); assertEquals("2026-01-01T00:00:00+08:00", rows.get(0).item().get("start_at")); assertEquals("2026-12-31T00:00:00+08:00", rows.get(0).item().get("end_at")); assertEquals(12000000L, rows.get(0).item().get("amount_minor"), "金额按分输出"); assertEquals("KD10086", rows.get(0).item().get("customer_id")); - assertEquals(1L, rows.get(0).item().get("property_id")); - assertEquals(1001L, rows.get(0).item().get("owner_user_id"), "负责人=稳定 user.id"); - assertEquals(1, rows.get(0).item().get("expire_remind")); + assertEquals("1", rows.get(0).item().get("property_id")); + assertEquals("1001", rows.get(0).item().get("owner_user_id"), "负责人=稳定 user.id"); + assertEquals(true, rows.get(0).item().get("expire_remind")); + assertEquals(90, rows.get(0).item().get("remind_days")); assertEquals(Timestamp.valueOf("2026-09-01 13:54:21").getTime() / 1000, rows.get(0).item().get("version")); assertEquals("2026-09-01T13:54:21+08:00", rows.get(0).item().get("deleted_at"), "软删行返回 tombstone"); } + + @Test + void contractsRejectUnknownLegacyStatus() { + ToolCallException error = assertThrows(ToolCallException.class, + () -> ContractsSyncEntity.publicStatus(99)); + + 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> 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()); + } } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ChangesToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ChangesToolsTest.java index 3ec4783..2843405 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ChangesToolsTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ChangesToolsTest.java @@ -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 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 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 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()); + } }