修复MCP任务结果提交契约校验

This commit is contained in:
andy
2026-07-12 19:39:14 +08:00
parent 807d045526
commit eda3e70873
14 changed files with 787 additions and 122 deletions

View File

@@ -561,7 +561,9 @@ V3 字段说明:
当前已支持的 V3 行为:
- 如果通过 MCP `th_hotel_submit_task_results` 调用MCP adapter 会在进入业务入站服务前按 `message_events[]` 顺序把 Agent 内部事件 ID 映射为本系统一基 `source_event_index`,并校验 `related_source_event_index``parent_source_event_index``related_source_event_indices[]` 是否悬空或重复。
- 如果通过 MCP `th_hotel_submit_task_results` 调用MCP adapter 会在进入业务入站服务前按 `message_events[]` 顺序把 Agent 内部事件 ID 映射为本系统一基 `source_event_index`,并校验 `related_source_event_index``parent_source_event_index``related_source_event_indices[]` 是否悬空或重复;成功响应会额外返回 `mapping_diagnostics`,该诊断不写入业务 JSON
- MCP adapter 只拦 transport 层问题,例如未知字段、缺失或重复 `source_event_index`、关系引用悬空或重复、V2 item schema 不完整V3 event 的业务契约问题,例如 unsupported `event_type`、不完整 `case_keys`、不完整 `manual_review`,继续由业务入站层保存为 `adapter_contract_error` transition。
- MCP 路径缺失 `source_message.source_message_id` 或整个 `source_message` 时,保留业务入站层 `MISSING_SOURCE_MESSAGE_ID` 错误语义。
- 40 条 P0.1 路由进入后端枚举 / 稳定配置。
- `route_code` 是稳定代码,不因路由总数从 42 调整为 40 而重编号;联调方不要按数字连续性判断合法性。
- 结构化 `S10/S99` 创建只读 `SOURCE_MESSAGE_ONLY` 任务,任务列表可见,订单列表不可见。
@@ -633,7 +635,7 @@ V3 字段说明:
| `ai_task_results[].source_event_index` | 是 | AI current 事件序号 |
| `ai_task_results[].catalog_code` | 是 | Skill 目录代码 |
| `ai_task_results[].skill_id` | 是 | Skill 标识 |
| `ai_task_results[].result_type` | 是 | 当前代码契约接受 `normal_task``manual_review``informational_message` 仅历史兼容 |
| `ai_task_results[].result_type` | 是 | 当前代码契约接受 `normal_task``manual_review``informational_message` 仅历史兼容MCP 路径会在 adapter 层校验 |
| `ai_task_results[].task_type` | 是 | AI 原始任务类型 |
| `ai_task_results[].task_subtype` | 否 | 业务动作 subtype |
| `ai_task_results[].case_keys` | 否 | 订单关联候选键 |

View File

@@ -134,6 +134,8 @@ REST request
M002 V3 后,写入工具优先接收结构化 `S10/S99``source_message + message_events[]` 业务根。`message_events[].source_event_index` 可以是 SuperAgent 内部事件 ID例如 `E_CHILD_1`MCP adapter 会在提交前按数组顺序映射为本系统一基数字索引,并校验跨事件关系是否悬空或重复。详细映射规则见 `submit-payload-mapping.md`
V3 业务根写入成功时MCP tool result 会返回 `mapping_diagnostics.source_event_index_mapping[]`,用于联调排查原始事件 ID 到本系统索引的映射;该字段不是业务任务字段,不会写入 TH Hotel 业务 payload。
## 9. 当前 checkpoint
当前 checkpoint

View File

@@ -4,7 +4,7 @@
| 项目 | 内容 |
| --- | --- |
| 文档版本 | 0.1 |
| 文档版本 | 0.2 |
| 日期 | 2026-07-12 |
| 状态 | 已落地第一版 |
| 适用范围 | `th_hotel_submit_task_results` 的 V3/P0.1 payload adapter、schema validator 和一次提交规则 |
@@ -34,13 +34,15 @@ server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/
| 形态 | 判断方式 | 处理方式 |
| --- | --- | --- |
| V3 业务根 | 同时包含 `source_message``message_events` | 校验根结构、source message、event 必填字段和关系索引,再按 `message_events[]` 顺序映射 `source_event_index` |
| V3 S10/S99 入口通知 | 同时包含 `source_message``route_code` | 校验 S10/S99 根字段和 source message 后透传给业务入站服务 |
| V2 兼容任务结果 | 包含 `ai_task_results` | 保留旧 `source_message_id + ai_task_results[]` 兼容路径 |
| V3 业务根 | 包含 `message_events` | 校验根结构、source message、event transport 字段和关系索引,再按 `message_events[]` 顺序映射 `source_event_index`event 业务合法性仍交给业务入站层落 `adapter_contract_error` |
| V3 S10/S99 入口通知 | 包含 `route_code` 或入口通知 `result_type` | 校验 S10/S99 根字段和 source message 后透传给业务入站服务 |
| V2 兼容任务结果 | 包含 `ai_task_results` | 保留旧 `source_message_id + ai_task_results[]` 兼容路径,并在 MCP 层校验完整 item schema |
未知根字段会在 MCP 层被拒绝,错误码为 `MCP_SUBMIT_PAYLOAD_INVALID`
`source_message_id` 缺失属于既有基础设施输入错误MCP adapter 不改写该错误通道;业务入站服务会返回 `MISSING_SOURCE_MESSAGE_ID`
`source_message_id` 缺失或整个 `source_message` 缺失属于既有基础设施输入错误MCP adapter 不改写该错误通道;业务入站服务会返回 `MISSING_SOURCE_MESSAGE_ID`
V3 业务根和 S10/S99 如果携带 MCP schema 中的兼容字段 `hotel_id``source_provider``source_channel`adapter 会接受并在转交业务入站层前移除。业务定位仍以系统酒店和 `source_message.source_message_id` 为准。
## 4. source_message_id 定义
@@ -72,10 +74,25 @@ MCP adapter 按 `message_events[]` 数组顺序生成一基数字索引:
- `parent_source_event_index` 输出为数字。
- `related_source_event_indices[]` 输出为字符串数字数组,保持原顺序。
- 悬空引用、重复引用、缺失事件 ID 都在 MCP 层拒绝。
- MCP tool result 会返回 `mapping_diagnostics.source_event_index_mapping[]`,记录原始事件 ID 到本系统索引的映射;该诊断不写入 `workflow_reservation_ai_transition.ai_payload_json`
## 6. ai_task_results[] 兼容 item schema
V2 兼容路径仍保留,`ai_task_results[]` item 以 REST 总契约为准,第一版 MCP schema 只暴露 object但业务入站会校验核心字段
V2 兼容路径仍保留,`ai_task_results[]` item 以 REST 总契约为准。当前 MCP `tools/list` 已暴露 item schemaadapter 也会在提交业务层前校验必填字段、字段类型、允许 `result_type`、未知字段和根级 `extraction_warnings`
必填字段:
- `source_event_index`
- `catalog_code`
- `skill_id`
- `result_type`
- `task_type`
允许的 `result_type`
- `normal_task`
- `manual_review`
- `informational_message`,仅历史兼容
建议 item 结构:
@@ -216,6 +233,30 @@ MCP adapter 进入业务层前会把父事件关系映射为:
}
```
MCP tool result 同时返回非业务诊断:
```json
{
"mapping_diagnostics": {
"mapping_policy": "message_events_array_order_1_based",
"source_event_index_mapping": [
{
"original_source_event_index": "E_CHILD_1",
"mapped_source_event_index": 1
},
{
"original_source_event_index": "E_CHILD_2",
"mapped_source_event_index": 2
},
{
"original_source_event_index": "E_PARENT",
"mapped_source_event_index": 3
}
]
}
}
```
## 9. 成功示例:跨 Child Trace
当 Trace 覆盖完整 Parent split 的全部 Child 时,不能只绑定第一个 Child。业务结果应保留全部 Child 引用:
@@ -300,9 +341,14 @@ MCP adapter 会把它映射为:
覆盖内容:
- `tools/list` 暴露 V3 submit schema。
- 缺失 `source_message_id` 保持既有 `MISSING_SOURCE_MESSAGE_ID` 错误
- `tools/list` 暴露 V2 `ai_task_results[]` item schema 和 V3 `relationship_type`
- 缺失 `source_message_id` 或整个 `source_message` 保持既有 `MISSING_SOURCE_MESSAGE_ID` 错误。
- 未知根字段在业务层前被拒绝。
- V2 item 缺必填字段在业务层前被拒绝。
- 悬空 `related_source_event_indices` 在业务层前被拒绝。
- 重复 `related_source_event_indices` 在业务层前被拒绝。
- `E_CHILD_1/E_CHILD_2/E_PARENT` 按数组顺序映射为 `1/2/3`
- 跨 Child Trace 关系映射为完整 `["1", "2"]`,不压缩到第一个 Child。
- S10/S99 可通过 MCP 写入工具创建只读来源消息任务。
- V3 event 业务契约错误由业务入站层保存为 `adapter_contract_error` transition。
- 合法 Parent split 只创建一个 AI batch并返回 `accepted_count=3`

View File

@@ -99,6 +99,11 @@
| MCP-T05-013 | V3 多事件关系重复 | `related_source_event_indices=["E_CHILD_1","E_CHILD_1"]` | 返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不写 AI batch、订单或任务 |
| MCP-T05-014 | V3 根节点未知字段 | 根节点存在 `unexpected_root` | 返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不调用业务写入 Service |
| MCP-T05-015 | V3 跨 Child Trace | Trace event 关联全部 Child event | `related_source_event_indices[]` 保留全量关系并映射为真实索引,不压缩到第一个 Child |
| MCP-T05-016 | V3 缺失整个 source_message | 业务根或 S10/S99 没有 `source_message` | 返回既有 `MISSING_SOURCE_MESSAGE_ID` typed infrastructure error |
| MCP-T05-017 | V2 item 缺必填字段 | `ai_task_results[]` item 缺 `task_type` 等必填字段 | 返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不调用业务写入 Service |
| MCP-T05-018 | V3 event 业务契约错误 | event_type unsupported 但 transport 字段完整 | MCP adapter 不整批拒绝,业务入站层保存 `adapter_contract_error` transition |
| MCP-T05-019 | V3 S10 入口通知 | `route_code=S10` | 通过 MCP 写入只读 `SOURCE_MESSAGE_ONLY` 任务 |
| MCP-T05-020 | V3 S99 入口通知 | `route_code=S99``manual_review` 完整 | 通过 MCP 写入只读 `SOURCE_MESSAGE_ONLY` 任务 |
写入验证:
@@ -108,6 +113,8 @@
- MCP endpoint 不能在日志输出完整 `extracted_fields` 中的敏感内容。
- 失败响应应保留后端错误码和 message。
- MCP adapter 校验失败时不进入业务写入 Service且不自动重试。
- V3 成功响应返回 `mapping_diagnostics.source_event_index_mapping[]`,但 AI transition 业务 payload 不包含该诊断字段。
- V3 `relationship_type` 可在 event 根节点透传,也可在 `extracted_fields` 中作为业务关系字段MCP adapter 不据此派生业务含义。
## 9. MCP 鉴权和开关测试
@@ -136,7 +143,9 @@
- 5 个工具正常成功调用。
- 查询接口错误 envelope 不丢失。
- 任务结果写入成功和幂等重放正常。
- V3 submit payload adapter 的事件索引映射、悬空关系拒绝、重复关系拒绝和未知字段拒绝正常。
- V3 submit payload adapter 的事件索引映射、mapping 诊断、悬空关系拒绝、重复关系拒绝和未知字段拒绝正常。
- V2 `ai_task_results[]` item schema 和 adapter 校验正常。
- S10/S99 结构化入口通知可通过 MCP 写入工具。
- provider/channel 隔离正常。
- 受控正文不返回附件 URL。
- MCP auth 失败不进入业务 Service。

View File

@@ -347,7 +347,7 @@ POST /api/ai-query/v1/message-conversation/messages
### 7.4 输入 Schema
当前 MCP tool 已支持三种输入形态:
当前 MCP tool 已支持三种输入形态,真实机器可读 schema 以 `/mcp``tools/list` 返回为准
1. V3 业务根:`source_message + message_events[]`
2. V3 S10/S99 入口通知:`source_message + route_code`
@@ -355,6 +355,15 @@ POST /api/ai-query/v1/message-conversation/messages
MCP 层会先调用 `SuperAgentMcpSubmitPayloadAdapter` 做提交前校验和事件索引映射。`E1``E_CHILD_1``E_PARENT` 等 Agent 内部事件 ID 不会直接进入业务层adapter 会按 `message_events[]` 顺序生成本系统一基数字 `source_event_index`,并同步映射 `related_source_event_index``parent_source_event_index``related_source_event_indices[]`
补充规则:
- V3 业务根或 S10/S99 如果携带 `hotel_id``source_provider``source_channel`MCP adapter 会接受这些兼容字段,但转交业务入站层前会移除;业务定位仍以系统酒店和外部 `source_message_id` 为准。
- 缺失 `source_message.source_message_id` 或整个 `source_message`MCP adapter 不改写错误,业务入站层返回 `MISSING_SOURCE_MESSAGE_ID`
- V3 event 业务合法性问题,例如 unsupported `event_type`、不完整 `case_keys`、不完整 `manual_review`,由业务入站层保存为 `adapter_contract_error` transitionMCP adapter 只拦 transport 必需问题,包括未知字段、`source_event_index` 缺失/重复、关系悬空/重复。
- V2 `ai_task_results[]` item 已在 MCP schema 和 adapter 中完整校验,必填字段为 `source_event_index``catalog_code``skill_id``result_type``task_type`
下面 JSON 是关键结构节选;不要手工复制为最终 schema联调时应以 `tools/list` 实时返回为准。
```json
{
"type": "object",
@@ -458,7 +467,27 @@ MCP 层会先调用 `SuperAgentMcpSubmitPayloadAdapter` 做提交前校验和事
"type": "array",
"description": "V2 兼容字段AI 拆分出的任务结果,必须保留数组顺序",
"items": {
"type": "object"
"type": "object",
"additionalProperties": false,
"required": ["source_event_index", "catalog_code", "skill_id", "result_type", "task_type"],
"properties": {
"source_event_index": {
"type": ["integer", "string"]
},
"catalog_code": {
"type": "string"
},
"skill_id": {
"type": "string"
},
"result_type": {
"type": "string",
"enum": ["normal_task", "manual_review", "informational_message"]
},
"task_type": {
"type": "string"
}
}
}
},
"extraction_warnings": {
@@ -469,7 +498,18 @@ MCP 层会先调用 `SuperAgentMcpSubmitPayloadAdapter` 做提交前校验和事
}
}
},
"required": []
"required": [],
"oneOf": [
{
"required": ["source_message", "message_events", "case_candidates", "extraction_warnings", "unhandled_current_intents"]
},
{
"required": ["source_message", "route_code", "handler_type", "result_type", "current_or_history", "agent_assessment", "notification", "manual_review"]
},
{
"required": ["source_message_id", "ai_task_results"]
}
]
}
```
@@ -499,6 +539,7 @@ POST /api/integrations/superagent/task-results
- `items[].order_id`:订单 ID。
- `items[].task_id`:任务 ID。
- `items[].task_status`:任务状态。
- `mapping_diagnostics`:仅 MCP 写入工具在 V3 业务根映射时返回,记录 Agent 原始事件 ID 到本系统一基索引的映射;不写入业务 JSON。
完整字段以 `superagent-api-contract.md` 第 8 节为准。

View File

@@ -406,7 +406,7 @@ V3 建议拆成以下 checkpoint避免一次性重构过大
| M002-V3-CP5 | 同卡复核解阻 | 已完成第一版:支持 review_status、review_resolution.field_overrides[]、复核场景订单归属确认、JSON Pointer 校验和 READY 流转 |
| M002-V3-CP6 | P0 fixtures 回归 | 已完成第一版:引入 0711 P0 fixtures / validator 作为后端适配测试参考,覆盖 main_outcomes、candidate_gate、manual_review_resolution、source_identity_errors、parent_split_two_children、row_multiple_derived、allotment_scope其中 candidate_gate 是 Main Agent 调 Skill 前契约,后端以 validator 和 fixture reference 固化,不作为任务结果回调直接建任务 |
| M002-V3-CP7 | P0.1 Parent Group 路由修订 | 已完成:将 Parent split 父事件从旧 Cancel Booking 迁移为 Cancel Allotment路由总数 42 → 40并保留旧 payload 只读兼容 |
| M002-V3-CP8 | MCP submit 稳定性 | 已完成第一MCP `th_hotel_submit_task_results` 支持 V3 业务根、结构化 S10/S99 和 V2 兼容,新增提交前 payload adapter / schema validator`message_events[]` 顺序把 Agent 内部事件 ID 映射为本系统一基 `source_event_index`并拒绝未知字段、悬空关系和重复关系 |
| M002-V3-CP8 | MCP submit 稳定性 | 已完成 review 修正MCP `th_hotel_submit_task_results` 支持 V3 业务根、结构化 S10/S99 和 V2 兼容V2 item 已暴露完整 schema 并在提交前校验V3 `message_events[]` 顺序把 Agent 内部事件 ID 映射为本系统一基 `source_event_index`返回 `mapping_diagnostics` 但不污染业务 JSONMCP 层只拦 transport 问题event 业务契约问题继续由业务入站层落 `adapter_contract_error` |
## 13. 明确不做
@@ -443,6 +443,9 @@ V3 P0.1 不做以下事项:
- 订单 / 任务列表、任务详情、草稿保存、最终确认、OPERA 模拟骨架和审计列表。
- SuperAgent 查询上下文接口 1、2以及邮件会话相关查询。
- MCP `th_hotel_submit_task_results` 的 V3/P0.1 payload adapter 和提交前校验;`E1/E_CHILD_1/E_PARENT` 等 Agent 内部事件 ID 不直接进入业务层,由 MCP adapter 映射为 `1/2/3` 等本系统一基索引,跨 Child Trace / Parent split 多事件关系保留顺序并拒绝悬空或重复引用。
- MCP submit V3 成功响应返回 `mapping_diagnostics.source_event_index_mapping[]`,用于排查 Agent 内部事件 ID 到本系统索引的映射;该诊断不写入 AI transition 业务 payload。
- MCP submit V2 兼容路径已在 `tools/list` 暴露 `ai_task_results[]` item schema并在 adapter 层校验必填字段、字段类型、允许 `result_type` 和未知字段。
- MCP submit 对缺失 `source_message.source_message_id` 或整个 `source_message` 保留业务入站层 `MISSING_SOURCE_MESSAGE_ID` 错误语义;对 V3 event 业务契约问题不提前整批拒绝,由业务入站层保存 `adapter_contract_error` transition。
仍需后续 checkpoint 实现:

View File

@@ -0,0 +1,16 @@
package cn.nianxx.thhotel.integrations.mcp.superagent.common.dto;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.Map;
/**
* MCP submit payload 适配结果。
*
* @param payload 可提交给业务入站服务的稳定 payload
* @param mappingDiagnostics MCP 层 transport 映射诊断,只用于返回给调用方,不写入业务 JSON
*/
public record SuperAgentMcpSubmitPayloadAdaptation(
JsonNode payload,
Map<String, Object> mappingDiagnostics
) {
}

View File

@@ -1,5 +1,6 @@
package cn.nianxx.thhotel.integrations.mcp.superagent.service;
import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpSubmitPayloadAdaptation;
import com.fasterxml.jackson.databind.JsonNode;
/**
@@ -8,7 +9,7 @@ import com.fasterxml.jackson.databind.JsonNode;
public interface SuperAgentMcpSubmitPayloadAdapter {
/**
* 校验并转换 th_hotel_submit_task_results 的 arguments返回可提交给业务入站服务的稳定 payload。
* 校验并转换 th_hotel_submit_task_results 的 arguments返回可提交给业务入站服务的稳定 payload 和 MCP 诊断
*/
JsonNode adapt(JsonNode arguments);
SuperAgentMcpSubmitPayloadAdaptation adapt(JsonNode arguments);
}

View File

@@ -1,6 +1,7 @@
package cn.nianxx.thhotel.integrations.mcp.superagent.service.impl;
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException;
import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpSubmitPayloadAdaptation;
import cn.nianxx.thhotel.integrations.mcp.superagent.common.request.SuperAgentMcpJsonRpcRequest;
import cn.nianxx.thhotel.integrations.mcp.superagent.common.request.SuperAgentMcpToolCallParams;
import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpJsonRpcResponse;
@@ -16,14 +17,17 @@ import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjec
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationMessageConversationQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryErrorResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryResponse;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiV3EventType;
import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultResponse;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiQueryService;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService;
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiQueryException;
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiTaskIntakeException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -260,14 +264,32 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
"MCP 写入工具未启用。",
Map.of("tool", TOOL_SUBMIT_TASK_RESULTS)));
}
JsonNode mappedPayload = submitPayloadAdapter.adapt(arguments);
String rawBody = objectMapper.writeValueAsString(mappedPayload);
SuperAgentMcpSubmitPayloadAdaptation adaptation = submitPayloadAdapter.adapt(arguments);
String rawBody = objectMapper.writeValueAsString(adaptation.payload());
SuperAgentTaskResultResponse response = intakeService.accept(
rawBody,
MCP_CLIENT_ID,
null,
hotelContextService.resolveSystemHotelId());
return SuperAgentMcpToolCallResult.success(TOOL_SUBMIT_TASK_RESULTS + " 调用成功。", response);
return SuperAgentMcpToolCallResult.success(
TOOL_SUBMIT_TASK_RESULTS + " 调用成功。",
submitStructuredContent(response, adaptation.mappingDiagnostics()));
}
/**
* 构造写入工具结构化响应,将 MCP transport 诊断放在业务响应之外,避免污染业务入站 JSON。
*/
private Map<String, Object> submitStructuredContent(
SuperAgentTaskResultResponse response,
Map<String, Object> mappingDiagnostics) {
Map<String, Object> structuredContent = objectMapper.convertValue(
response,
new TypeReference<LinkedHashMap<String, Object>>() {
});
if (mappingDiagnostics != null && !mappingDiagnostics.isEmpty()) {
structuredContent.put("mapping_diagnostics", mappingDiagnostics);
}
return structuredContent;
}
/**
@@ -398,12 +420,32 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
propertiesMap.put("ai_task_results", Map.of(
"type", "array",
"description", "V2 兼容字段AI 拆分出的任务结果,必须保留数组顺序",
"items", Map.of("type", "object")));
"items", legacyV2TaskResultItemSchema()));
propertiesMap.put("extraction_warnings", Map.of(
"type", "array",
"description", "AI 抽取警告V3/V2 都允许,缺省按空数组处理",
"items", Map.of("type", "object")));
return objectSchema(propertiesMap, List.of());
Map<String, Object> schema = new LinkedHashMap<>(objectSchema(propertiesMap, List.of()));
schema.put("oneOf", List.of(
Map.of(
"required", List.of(
"source_message",
"message_events",
"case_candidates",
"extraction_warnings",
"unhandled_current_intents")),
Map.of(
"required", List.of(
"source_message",
"route_code",
"handler_type",
"result_type",
"current_or_history",
"agent_assessment",
"notification",
"manual_review")),
Map.of("required", List.of("source_message_id", "ai_task_results"))));
return schema;
}
/**
@@ -431,7 +473,10 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
*/
private Map<String, Object> messageEventSchema() {
Map<String, Object> propertiesMap = new LinkedHashMap<>();
propertiesMap.put("event_type", stringField("V3 业务事件类型"));
propertiesMap.put("event_type", Map.of(
"type", "string",
"enum", v3EventTypeSchemaValues(),
"description", "V3 业务事件类型Need Manual Review 等业务契约问题由业务入站层落 adapter_contract_error"));
propertiesMap.put("event_role", stringField("V3 事件来源角色"));
propertiesMap.put("source_event_index", Map.of(
"type", List.of("string", "integer"),
@@ -455,6 +500,7 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
propertiesMap.put("linked_task_group_id", nullableStringField("联动任务组 ID"));
propertiesMap.put("blocked_until_parent_completed", Map.of("type", List.of("boolean", "null"), "description", "是否被父任务阻塞"));
propertiesMap.put("related_event_type", nullableStringField("关系目标事件类型"));
propertiesMap.put("relationship_type", nullableStringField("Parent split 等联动事件关系类型MCP 只透传不派生业务语义"));
propertiesMap.put("requires_downstream_hard_validation", Map.of("type", List.of("boolean", "null"), "description", "是否要求下游硬校验"));
propertiesMap.put("contract_errors", Map.of("type", "array", "description", "Agent 暴露的契约错误", "items", Map.of("type", "object")));
propertiesMap.put("missing_fields", Map.of("type", "array", "description", "根级缺失字段", "items", Map.of("type", "string")));
@@ -472,6 +518,51 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
"manual_review"));
}
/**
* 返回 V3 event_type schema 允许值Need Manual Review 是人工复核兜底事件,不属于 activeCodes 但业务层支持。
*/
private List<String> v3EventTypeSchemaValues() {
List<String> values = new ArrayList<>(ReservationAiV3EventType.activeCodes());
values.add("Need Manual Review");
return values.stream().sorted().toList();
}
/**
* 构造 V2 兼容 ai_task_results[] item 的 MCP schema。
*/
private Map<String, Object> legacyV2TaskResultItemSchema() {
Map<String, Object> propertiesMap = new LinkedHashMap<>();
propertiesMap.put("source_event_index", Map.of(
"type", List.of("integer", "string"),
"description", "V2 兼容事件序号,必须非空"));
propertiesMap.put("catalog_code", stringField("Skill 目录代码"));
propertiesMap.put("skill_id", stringField("Skill 标识"));
propertiesMap.put("result_type", Map.of(
"type", "string",
"enum", List.of("normal_task", "manual_review", "informational_message"),
"description", "V2 兼容结果类型informational_message 仅历史兼容"));
propertiesMap.put("task_type", stringField("AI 原始任务类型"));
propertiesMap.put("task_subtype", nullableStringField("业务动作 subtype"));
propertiesMap.put("current_or_history", nullableStringField("current/history 标记"));
propertiesMap.put("case_keys", Map.of("type", "object", "description", "订单关联候选键"));
propertiesMap.put("visible_reason", nullableStringField("前端可见原因"));
propertiesMap.put("relevant_message_excerpt", nullableStringField("当前任务证据摘录"));
propertiesMap.put("attachments", Map.of("type", "array", "description", "附件引用", "items", Map.of("type", "object")));
propertiesMap.put("file_references", Map.of("type", "array", "description", "文件引用", "items", Map.of("type", "object")));
propertiesMap.put("context_used", Map.of("type", "object", "description", "使用的上下文"));
propertiesMap.put("extracted_fields", Map.of("type", "object", "description", "业务字段主体"));
propertiesMap.put("manual_review", Map.of("type", List.of("object", "null"), "description", "人工复核信息"));
propertiesMap.put("informational_message", Map.of("type", List.of("object", "null"), "description", "历史信息类消息兼容字段"));
propertiesMap.put("additional_operations", Map.of("type", "array", "description", "补充操作", "items", Map.of("type", "object")));
propertiesMap.put("idempotency_key", nullableStringField("调用方幂等键,通常为空"));
return objectSchema(propertiesMap, List.of(
"source_event_index",
"catalog_code",
"skill_id",
"result_type",
"task_type"));
}
private Map<String, Object> objectSchema(Map<String, Object> propertiesMap, List<String> required) {
return Map.of(
"type", "object",

View File

@@ -1,10 +1,12 @@
package cn.nianxx.thhotel.integrations.mcp.superagent.service.impl;
import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpSubmitPayloadAdaptation;
import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpSubmitPayloadAdapter;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -25,7 +27,14 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi
"message_events",
"case_candidates",
"extraction_warnings",
"unhandled_current_intents");
"unhandled_current_intents",
"hotel_id",
"source_provider",
"source_channel");
private static final Set<String> V3_TRANSPORT_COMPATIBILITY_ROOT_FIELDS = Set.of(
"hotel_id",
"source_provider",
"source_channel");
private static final Set<String> V3_SOURCE_MESSAGE_FIELDS = Set.of(
"source_message_id",
"subject",
@@ -60,39 +69,10 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi
"linked_task_group_id",
"blocked_until_parent_completed",
"contract_errors",
"missing_fields");
private static final List<String> V3_EVENT_REQUIRED_FIELDS = List.of(
"event_type",
"event_role",
"source_event_index",
"current_or_history",
"case_keys",
"relevant_message_excerpt",
"attachments",
"file_references",
"context_used",
"extracted_fields",
"manual_review");
private static final Set<String> V3_ACTIVE_EVENT_TYPES = Set.of(
"New Booking",
"Update Booking / Amendment",
"Cancel Booking",
"Cancel Allotment",
"Voucher Received",
"Payment Evidence",
"Rooming List",
"AMEND GROUP CODE",
"Trace",
"TA RECORDER",
"Invoice Generation",
"Invoice Received",
"Payment Notice",
"Manual RateCode");
private static final List<String> V3_CASE_KEY_FIELDS = List.of(
"group_code",
"confirmation_number",
"reservation_number",
"block_code");
"missing_fields",
"relationship_type");
private static final List<String> V3_EVENT_TRANSPORT_REQUIRED_FIELDS = List.of(
"source_event_index");
private static final Set<String> V3_NOTIFICATION_ROOT_FIELDS = Set.of(
"source_message",
"route_code",
@@ -101,7 +81,10 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi
"current_or_history",
"agent_assessment",
"notification",
"manual_review");
"manual_review",
"hotel_id",
"source_provider",
"source_channel");
private static final List<String> V3_NOTIFICATION_REQUIRED_FIELDS = List.of(
"source_message",
"route_code",
@@ -118,6 +101,35 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi
"source_message_id",
"ai_task_results",
"extraction_warnings");
private static final Set<String> LEGACY_V2_ITEM_FIELDS = Set.of(
"source_event_index",
"catalog_code",
"skill_id",
"result_type",
"task_type",
"task_subtype",
"current_or_history",
"case_keys",
"visible_reason",
"relevant_message_excerpt",
"attachments",
"file_references",
"context_used",
"extracted_fields",
"manual_review",
"informational_message",
"additional_operations",
"idempotency_key");
private static final List<String> LEGACY_V2_ITEM_REQUIRED_FIELDS = List.of(
"source_event_index",
"catalog_code",
"skill_id",
"result_type",
"task_type");
private static final Set<String> LEGACY_V2_RESULT_TYPES = Set.of(
"normal_task",
"manual_review",
"informational_message");
private static final List<String> REQUIRED_V3_ROOT_ARRAY_FIELDS = List.of(
"message_events",
"case_candidates",
@@ -137,24 +149,24 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi
* 根据 submit arguments 的根结构选择 V3 业务根、S10/S99 通知或 V2 兼容映射。
*/
@Override
public JsonNode adapt(JsonNode arguments) {
public SuperAgentMcpSubmitPayloadAdaptation adapt(JsonNode arguments) {
if (arguments == null || !arguments.isObject()) {
throw invalid("MCP submit arguments 必须是 JSON object。", "arguments");
}
if (isV3BusinessRoot(arguments)) {
if (isV3SourceMessageIdentityMissing(arguments)) {
return arguments;
return new SuperAgentMcpSubmitPayloadAdaptation(arguments, Map.of());
}
return adaptV3BusinessRoot(arguments);
}
if (isV3SourceMessageNotification(arguments)) {
if (isV3SourceMessageIdentityMissing(arguments)) {
return arguments;
return new SuperAgentMcpSubmitPayloadAdaptation(arguments, Map.of());
}
validateRootFields(arguments, V3_NOTIFICATION_ROOT_FIELDS);
validateRequiredFields(arguments, V3_NOTIFICATION_REQUIRED_FIELDS);
validateSourceMessage(arguments.path("source_message"));
return arguments;
return new SuperAgentMcpSubmitPayloadAdaptation(removeCompatibilityFields(arguments), Map.of());
}
if (arguments.has("ai_task_results")) {
validateRootFields(arguments, LEGACY_V2_ROOT_FIELDS);
@@ -164,7 +176,9 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi
if (!arguments.path("ai_task_results").isArray()) {
throw invalid("ai_task_results 必须是数组。", "ai_task_results");
}
return arguments;
validateLegacyV2Items(arguments.path("ai_task_results"));
validateLegacyExtractionWarnings(arguments);
return new SuperAgentMcpSubmitPayloadAdaptation(arguments, Map.of());
}
throw invalid("MCP submit arguments 不是支持的 V3 或 V2 任务结果结构。", "arguments");
}
@@ -172,7 +186,7 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi
/**
* 校验并转换 V3 业务根,将业务事件 ID 映射为本系统一基 source_event_index。
*/
private JsonNode adaptV3BusinessRoot(JsonNode root) {
private SuperAgentMcpSubmitPayloadAdaptation adaptV3BusinessRoot(JsonNode root) {
validateRootFields(root, V3_BUSINESS_ROOT_FIELDS);
validateSourceMessage(root.path("source_message"));
for (String fieldName : REQUIRED_V3_ROOT_ARRAY_FIELDS) {
@@ -182,10 +196,10 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi
}
ArrayNode events = (ArrayNode) root.path("message_events");
if (events.isEmpty()) {
return root.deepCopy();
return new SuperAgentMcpSubmitPayloadAdaptation(removeCompatibilityFields(root), Map.of());
}
Map<String, Integer> eventIndexMap = eventIndexMap(events);
ObjectNode mappedRoot = root.deepCopy();
ObjectNode mappedRoot = removeCompatibilityFields(root);
ArrayNode mappedEvents = (ArrayNode) mappedRoot.path("message_events");
for (int index = 0; index < mappedEvents.size(); index++) {
JsonNode eventNode = mappedEvents.get(index);
@@ -194,13 +208,20 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi
}
ObjectNode event = (ObjectNode) eventNode;
validateRootFields(event, V3_EVENT_FIELDS);
validateV3EventShape(event);
validateRequiredFields(event, V3_EVENT_TRANSPORT_REQUIRED_FIELDS);
if (!hasText(event.path("source_event_index"))) {
throw invalid("message_events[].source_event_index 不能为空。", "source_event_index");
}
event.put("source_event_index", index + 1);
mapSingleEventReference(event, eventIndexMap, "related_source_event_index", false);
mapSingleEventReference(event, eventIndexMap, "parent_source_event_index", true);
mapMultipleEventReferences(event, eventIndexMap);
}
return mappedRoot;
return new SuperAgentMcpSubmitPayloadAdaptation(
mappedRoot,
Map.of(
"mapping_policy", "message_events_array_order_1_based",
"source_event_index_mapping", sourceEventIndexMapping(eventIndexMap)));
}
/**
@@ -214,7 +235,7 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi
throw invalid("message_events[] item 必须是 object。", "message_events");
}
validateRootFields(event, V3_EVENT_FIELDS);
validateV3EventShape((ObjectNode) event);
validateRequiredFields(event, V3_EVENT_TRANSPORT_REQUIRED_FIELDS);
String rawIndex = referenceText(event.path("source_event_index"));
if (rawIndex == null) {
throw invalid("message_events[].source_event_index 不能为空。", "source_event_index");
@@ -226,6 +247,20 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi
return result;
}
/**
* 构造原始业务事件 ID 到本系统一基索引的映射诊断。
*/
private List<Map<String, Object>> sourceEventIndexMapping(Map<String, Integer> eventIndexMap) {
List<Map<String, Object>> result = new ArrayList<>();
for (Map.Entry<String, Integer> entry : eventIndexMap.entrySet()) {
Map<String, Object> mapping = new LinkedHashMap<>();
mapping.put("original_source_event_index", entry.getKey());
mapping.put("mapped_source_event_index", entry.getValue());
result.add(mapping);
}
return result;
}
/**
* 映射单事件关系字段parent_source_event_index 按业务入站服务约定输出数字。
*/
@@ -297,29 +332,16 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi
}
/**
* 校验 V3 业务事件最小结构和字段类型,提交前 fail closed
* 校验 V2 兼容 item 完整结构,避免 MCP 调用方发送半结构化对象后才落到业务层失败
*/
private void validateV3EventShape(ObjectNode event) {
validateRequiredFields(event, V3_EVENT_REQUIRED_FIELDS);
if ((!V3_ACTIVE_EVENT_TYPES.contains(referenceText(event.path("event_type")))
&& !"Need Manual Review".equals(referenceText(event.path("event_type"))))
|| !hasText(event.path("event_role"))
|| !"current".equals(referenceText(event.path("current_or_history")))
|| !hasText(event.path("source_event_index"))
|| !isValidCaseKeys(event.path("case_keys"))
|| !event.path("relevant_message_excerpt").isTextual()
|| !event.path("attachments").isArray()
|| !event.path("file_references").isArray()
|| !event.path("context_used").isObject()
|| !event.path("extracted_fields").isObject()) {
throw invalid("message_event 字段类型或基础值无效。", "message_events");
}
JsonNode manualReview = event.get("manual_review");
if (manualReview == null || (!manualReview.isNull() && !manualReview.isObject())) {
throw invalid("message_event.manual_review 必须为 null 或对象。", "manual_review");
}
if ("Need Manual Review".equals(referenceText(event.path("event_type"))) && manualReview.isNull()) {
throw invalid("Need Manual Review 必须携带 manual_review。", "manual_review");
private void validateLegacyV2Items(JsonNode taskResults) {
for (JsonNode item : taskResults) {
if (item == null || !item.isObject()) {
throw invalid("ai_task_results[] item 必须是 object。", "ai_task_results");
}
validateRootFields(item, LEGACY_V2_ITEM_FIELDS);
validateLegacyV2RequiredFields(item);
validateLegacyV2Item((ObjectNode) item);
}
}
@@ -351,38 +373,118 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi
}
/**
* 校验 case_keys 四字段结构,避免 source_event_index 映射后才被业务层拒绝
* 校验 V2 兼容 item 的字段类型、允许值和常见子对象形态
*/
private boolean isValidCaseKeys(JsonNode caseKeys) {
if (caseKeys == null || !caseKeys.isObject()) {
return false;
private void validateLegacyV2Item(ObjectNode item) {
if (!hasText(item.path("source_event_index"))) {
throw invalid("ai_task_results[].source_event_index 不能为空。", "ai_task_results[].source_event_index");
}
for (String key : V3_CASE_KEY_FIELDS) {
if (!caseKeys.has(key) || !isStringOrNull(caseKeys.path(key))) {
return false;
validateText(item, "catalog_code");
validateText(item, "skill_id");
validateText(item, "result_type");
validateText(item, "task_type");
if (!LEGACY_V2_RESULT_TYPES.contains(referenceText(item.path("result_type")))) {
throw invalid("ai_task_results[].result_type 不在允许范围。", "ai_task_results[].result_type");
}
validateOptionalText(item, "task_subtype");
validateOptionalText(item, "current_or_history");
validateOptionalText(item, "visible_reason");
validateOptionalText(item, "relevant_message_excerpt");
validateOptionalText(item, "idempotency_key");
validateOptionalObject(item, "case_keys");
validateOptionalObject(item, "context_used");
validateOptionalObject(item, "extracted_fields");
validateOptionalObjectOrNull(item, "manual_review");
validateOptionalObjectOrNull(item, "informational_message");
validateOptionalArray(item, "attachments");
validateOptionalArray(item, "file_references");
validateOptionalArray(item, "additional_operations");
}
/**
* 校验 V2 item 必填字段,并返回包含数组路径的错误字段名。
*/
private void validateLegacyV2RequiredFields(JsonNode item) {
for (String requiredField : LEGACY_V2_ITEM_REQUIRED_FIELDS) {
if (!item.has(requiredField)) {
throw invalid(
"MCP submit payload 缺少必填字段。",
"ai_task_results[]." + requiredField);
}
}
Iterator<String> fieldNames = caseKeys.fieldNames();
while (fieldNames.hasNext()) {
if (!V3_CASE_KEY_FIELDS.contains(fieldNames.next())) {
return false;
}
}
/**
* 校验 V2 根级 extraction_warnings存在时必须是数组。
*/
private void validateLegacyExtractionWarnings(JsonNode arguments) {
JsonNode extractionWarnings = arguments.get("extraction_warnings");
if (extractionWarnings != null && !extractionWarnings.isArray()) {
throw invalid("extraction_warnings 必须是数组。", "extraction_warnings");
}
}
/**
* 校验 V2 必填文本字段。
*/
private void validateText(ObjectNode item, String fieldName) {
if (!hasText(item.path(fieldName))) {
throw invalid("ai_task_results[]." + fieldName + " 不能为空。", "ai_task_results[]." + fieldName);
}
}
/**
* 校验 V2 可选文本字段。
*/
private void validateOptionalText(ObjectNode item, String fieldName) {
JsonNode value = item.get(fieldName);
if (value != null && !isStringOrNull(value)) {
throw invalid("ai_task_results[]." + fieldName + " 必须是字符串或 null。", "ai_task_results[]." + fieldName);
}
}
/**
* 校验 V2 可选对象字段。
*/
private void validateOptionalObject(ObjectNode item, String fieldName) {
JsonNode value = item.get(fieldName);
if (value != null && !value.isObject()) {
throw invalid("ai_task_results[]." + fieldName + " 必须是 object。", "ai_task_results[]." + fieldName);
}
}
/**
* 校验 V2 可选对象或 null 字段。
*/
private void validateOptionalObjectOrNull(ObjectNode item, String fieldName) {
JsonNode value = item.get(fieldName);
if (value != null && !value.isObject() && !value.isNull()) {
throw invalid("ai_task_results[]." + fieldName + " 必须是 object 或 null。", "ai_task_results[]." + fieldName);
}
}
/**
* 校验 V2 可选数组字段。
*/
private void validateOptionalArray(ObjectNode item, String fieldName) {
JsonNode value = item.get(fieldName);
if (value != null && !value.isArray()) {
throw invalid("ai_task_results[]." + fieldName + " 必须是数组。", "ai_task_results[]." + fieldName);
}
return true;
}
/**
* 判断是否为 V3 业务根。
*/
private boolean isV3BusinessRoot(JsonNode arguments) {
return arguments.has("source_message") && arguments.has("message_events");
return arguments.has("message_events");
}
/**
* 判断是否为 V3 S10/S99 入口通知。
*/
private boolean isV3SourceMessageNotification(JsonNode arguments) {
return arguments.has("source_message") && arguments.has("route_code");
return arguments.has("route_code") || arguments.has("result_type");
}
/**
@@ -395,6 +497,17 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi
|| !hasText(sourceMessage.path("source_message_id"));
}
/**
* 移除 MCP transport 兼容字段,避免进入业务入站 exact root 校验。
*/
private ObjectNode removeCompatibilityFields(JsonNode root) {
ObjectNode copy = root.deepCopy();
for (String fieldName : V3_TRANSPORT_COMPATIBILITY_ROOT_FIELDS) {
copy.remove(fieldName);
}
return copy;
}
/**
* 提取事件引用文本,数字和字符串都统一成字符串键。
*/

View File

@@ -0,0 +1,44 @@
package cn.nianxx.thhotel.workflows.reservation.common.enums;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
/**
* SuperAgent 0711 P0 V3 业务事件类型枚举,集中维护后端可识别的 active event_type。
*/
public enum ReservationAiV3EventType {
NEW_BOOKING("New Booking"),
UPDATE_BOOKING_AMENDMENT("Update Booking / Amendment"),
CANCEL_BOOKING("Cancel Booking"),
CANCEL_ALLOTMENT("Cancel Allotment"),
VOUCHER_RECEIVED("Voucher Received"),
PAYMENT_EVIDENCE("Payment Evidence"),
ROOMING_LIST("Rooming List"),
AMEND_GROUP_CODE("AMEND GROUP CODE"),
INVOICE_GENERATION("Invoice Generation"),
INVOICE_RECEIVED("Invoice Received"),
PAYMENT_NOTICE("Payment Notice"),
TRACE("Trace"),
MANUAL_RATE_CODE("Manual RateCode"),
TA_RECORDER("TA RECORDER");
private final String code;
ReservationAiV3EventType(String code) {
this.code = code;
}
public String code() {
return code;
}
/**
* 返回所有 active event_type 代码,供业务入站和外部适配层共享。
*/
public static Set<String> activeCodes() {
return Arrays.stream(values())
.map(ReservationAiV3EventType::code)
.collect(Collectors.toUnmodifiableSet());
}
}

View File

@@ -13,6 +13,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskDraft;
import cn.nianxx.thhotel.workflows.reservation.common.enums.AiResultType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiRouteDefinition;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiSystemProcessCategory;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiV3EventType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationBusinessKeySource;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus;
@@ -137,21 +138,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
"conflicting_points",
"suggested_human_actions",
"evidence_to_check");
private static final Set<String> V3_ACTIVE_EVENT_TYPES = Set.of(
"New Booking",
"Update Booking / Amendment",
"Cancel Booking",
"Cancel Allotment",
"Voucher Received",
"Payment Evidence",
"Rooming List",
"AMEND GROUP CODE",
"Invoice Generation",
"Invoice Received",
"Payment Notice",
"Trace",
"Manual RateCode",
"TA RECORDER");
private static final Set<String> V3_ACTIVE_EVENT_TYPES = ReservationAiV3EventType.activeCodes();
private final ObjectMapper objectMapper;
private final SourceMessageInboxRepository sourceMessageInboxRepository;

View File

@@ -133,8 +133,16 @@ class SuperAgentMcpControllerTest {
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message").exists())
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.message_events").exists())
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.ai_task_results").exists())
.andExpect(jsonPath("$.result.tools[4].inputSchema.oneOf.length()").value(3))
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.message_events.items.properties.source_event_index.description")
.value(containsString("MCP Adapter")))
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.message_events.items.properties.relationship_type").exists())
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.ai_task_results.items.required[0]")
.value("source_event_index"))
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.ai_task_results.items.required[4]")
.value("task_type"))
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.ai_task_results.items.properties.result_type.enum.length()")
.value(3))
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_provider.description")
.value(containsString("兼容字段")))
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_channel.description")

View File

@@ -1,8 +1,8 @@
package cn.nianxx.thhotel.integrations.mcp.superagent.control;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -125,6 +125,58 @@ class SuperAgentMcpSubmitEnabledControllerTest {
.andExpect(jsonPath("$.result.structuredContent.error.details.http_status").value(400));
}
@Test
void shouldKeepInfrastructureErrorWhenV3SourceMessageMissing() throws Exception {
String businessRoot = parentSplitBusinessRootWithoutSourceMessage("mail-mcp-v3-missing-source-object-001");
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-v3-missing-source-object-001", businessRoot)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(true))
.andExpect(jsonPath("$.result.structuredContent.error.code").value("MISSING_SOURCE_MESSAGE_ID"))
.andExpect(jsonPath("$.result.structuredContent.error.details.http_status").value(400));
}
@Test
void shouldRejectIncompleteV2ItemBeforeDelegatingSubmit() throws Exception {
String externalId = "mail-mcp-v2-incomplete-item-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
String body = """
{
"jsonrpc": "2.0",
"id": "mcp-submit-v2-incomplete-item-001",
"method": "tools/call",
"params": {
"name": "th_hotel_submit_task_results",
"arguments": {
"source_message_id": "%s",
"ai_task_results": [
{
"source_event_index": 1,
"catalog_code": "S01",
"skill_id": "S01_new_booking_skill",
"result_type": "normal_task"
}
]
}
}
}
""".formatted(externalId);
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(true))
.andExpect(jsonPath("$.result.structuredContent.error.code").value("MCP_SUBMIT_PAYLOAD_INVALID"))
.andExpect(jsonPath("$.result.structuredContent.error.details.field").value("ai_task_results[].task_type"));
assertNoReservationWorkflowRows(source.inboxId());
}
@Test
void shouldRejectDanglingRelatedEventIndexBeforeDelegatingSubmit() throws Exception {
String externalId = "mail-mcp-v3-dangling-related-001";
@@ -176,6 +228,14 @@ class SuperAgentMcpSubmitEnabledControllerTest {
.andExpect(jsonPath("$.result.structuredContent.items[0].source_event_index").value(1))
.andExpect(jsonPath("$.result.structuredContent.items[1].source_event_index").value(2))
.andExpect(jsonPath("$.result.structuredContent.items[2].source_event_index").value(3))
.andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[0].original_source_event_index")
.value("E_CHILD_1"))
.andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[0].mapped_source_event_index")
.value(1))
.andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[2].original_source_event_index")
.value("E_PARENT"))
.andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[2].mapped_source_event_index")
.value(3))
.andReturn();
Long batchCount = jdbcTemplate.queryForObject("""
@@ -197,7 +257,118 @@ class SuperAgentMcpSubmitEnabledControllerTest {
.isEqualTo("1");
assertThat(parentPayload.path("v3_message_event").path("related_source_event_indices").get(1).asText())
.isEqualTo("2");
assertThat(result.getResponse().getContentAsString()).doesNotContain("E_CHILD_1");
assertThat(parentPayload.toString()).doesNotContain("mapping_diagnostics");
assertThat(result.getResponse().getContentAsString()).contains("E_CHILD_1");
}
@Test
void shouldMapCrossChildTraceRelationsAndSubmitOnce() throws Exception {
String externalId = "mail-mcp-v3-trace-cross-child-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-v3-trace-cross-child-001", traceCrossChildBusinessRoot(externalId))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(false))
.andExpect(jsonPath("$.result.structuredContent.accepted_count").value(3))
.andExpect(jsonPath("$.result.structuredContent.items[2].source_event_index").value(3))
.andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[2].original_source_event_index")
.value("E_TRACE"))
.andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[2].mapped_source_event_index")
.value(3));
String tracePayloadJson = jdbcTemplate.queryForObject("""
SELECT ai_payload_json
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND source_event_index = 3
""", String.class, source.inboxId());
JsonNode tracePayload = objectMapper.readTree(tracePayloadJson);
assertThat(tracePayload.path("v3_message_event").path("event_type").asText()).isEqualTo("Trace");
assertThat(tracePayload.path("v3_message_event").path("related_source_event_indices").get(0).asText())
.isEqualTo("1");
assertThat(tracePayload.path("v3_message_event").path("related_source_event_indices").get(1).asText())
.isEqualTo("2");
}
@Test
void shouldAllowRelationshipTypeOnV3EventAndLetBusinessLayerHandleIt() throws Exception {
String externalId = "mail-mcp-v3-relationship-root-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
String businessRoot = parentSplitBusinessRootWithRootRelationshipType(externalId);
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-v3-relationship-root-001", businessRoot)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(false))
.andExpect(jsonPath("$.result.structuredContent.accepted_count").value(3));
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(transitionCount).isPositive();
}
@Test
void shouldLetBusinessLayerPersistInvalidV3EventAsContractError() throws Exception {
String externalId = "mail-mcp-v3-business-contract-error-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
String businessRoot = businessRootWithInvalidEventShape(externalId);
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-v3-business-contract-error-001", businessRoot)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(false))
.andExpect(jsonPath("$.result.structuredContent.items[0].system_process_category")
.value("ADAPTER_CONTRACT_ERROR"))
.andExpect(jsonPath("$.result.structuredContent.items[0].adapter_error_code")
.value("EVENT_CONTRACT_INCOMPLETE"));
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND result_type = 'adapter_contract_error'
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(1L);
}
@Test
void shouldSubmitS10ThroughMcpAdapter() throws Exception {
String externalId = "mail-mcp-s10-001";
captureSourceMessage(externalId);
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-s10-001", s10NotificationRoot(externalId))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(false))
.andExpect(jsonPath("$.result.structuredContent.accepted_count").value(1))
.andExpect(jsonPath("$.result.structuredContent.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"));
}
@Test
void shouldSubmitS99ThroughMcpAdapter() throws Exception {
String externalId = "mail-mcp-s99-001";
captureSourceMessage(externalId);
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-s99-001", s99NotificationRoot(externalId))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(false))
.andExpect(jsonPath("$.result.structuredContent.accepted_count").value(1))
.andExpect(jsonPath("$.result.structuredContent.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"));
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
@@ -264,6 +435,137 @@ class SuperAgentMcpSubmitEnabledControllerTest {
return objectMapper.writeValueAsString(root);
}
private String parentSplitBusinessRootWithoutSourceMessage(String externalMessageId) throws Exception {
ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId));
root.remove("source_message");
return objectMapper.writeValueAsString(root);
}
private String parentSplitBusinessRootWithRootRelationshipType(String externalMessageId) throws Exception {
ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId));
ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
parentEvent.put("relationship_type", "linked_parent_release_after_child_split");
return objectMapper.writeValueAsString(root);
}
private String businessRootWithInvalidEventShape(String externalMessageId) throws Exception {
ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId));
ArrayNode events = objectMapper.createArrayNode();
ObjectNode invalidEvent = ((ObjectNode) root.path("message_events").get(0)).deepCopy();
invalidEvent.put("event_type", "Unsupported Event Type");
events.add(invalidEvent);
root.set("message_events", events);
return objectMapper.writeValueAsString(root);
}
private String traceCrossChildBusinessRoot(String externalMessageId) throws Exception {
ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId));
ArrayNode events = objectMapper.createArrayNode();
events.add(root.path("message_events").get(0));
events.add(root.path("message_events").get(1));
ObjectNode trace = objectMapper.createObjectNode();
trace.put("event_type", "Trace");
trace.put("event_role", "travel_agent_request");
trace.put("source_event_index", "E_TRACE");
trace.put("current_or_history", "current");
ObjectNode caseKeys = objectMapper.createObjectNode();
caseKeys.putNull("group_code");
caseKeys.putNull("confirmation_number");
caseKeys.putNull("reservation_number");
caseKeys.putNull("block_code");
trace.set("case_keys", caseKeys);
trace.put("relevant_message_excerpt", "Please add a shared trace to both child groups.");
trace.set("attachments", objectMapper.createArrayNode());
trace.set("file_references", objectMapper.createArrayNode());
trace.set("context_used", objectMapper.createObjectNode());
trace.put("related_event_type", "New Booking");
ArrayNode related = objectMapper.createArrayNode();
related.add("E_CHILD_1");
related.add("E_CHILD_2");
trace.set("related_source_event_indices", related);
ObjectNode extractedFields = objectMapper.createObjectNode();
extractedFields.put("trace_subtype", "general_request");
trace.set("extracted_fields", extractedFields);
trace.set("manual_review", objectMapper.nullNode());
events.add(trace);
root.set("message_events", events);
return objectMapper.writeValueAsString(root);
}
private String s10NotificationRoot(String externalMessageId) {
return """
{
"source_message": {
"source_message_id": "%s",
"subject": "Informational message",
"from": "agent@example.test",
"cc": [],
"received_at": "2026-07-12T04:00:00Z",
"source_channel": "Email"
},
"route_code": "S10",
"handler_type": "main_agent_outcome",
"result_type": "source_message_review_notification",
"current_or_history": "current",
"agent_assessment": {
"status": "no_booking_action_detected",
"reason_code": "no_booking_action_detected",
"automation_action": "none"
},
"notification": {
"required": true,
"notification_type": "source_message_review",
"show_source_message": true,
"requires_user_decision": true,
"visible_message": "纯信息类邮件,请人工查看原邮件。"
},
"manual_review": null
}
""".formatted(externalMessageId);
}
private String s99NotificationRoot(String externalMessageId) {
return """
{
"source_message": {
"source_message_id": "%s",
"subject": "Material unavailable",
"from": "agent@example.test",
"cc": [],
"received_at": "2026-07-12T04:00:00Z",
"source_channel": "Email"
},
"route_code": "S99",
"handler_type": "main_agent_outcome",
"result_type": "source_message_review_notification",
"current_or_history": "current",
"agent_assessment": {
"status": "material_package_unavailable",
"reason_code": "material_package_unavailable",
"automation_action": "none"
},
"notification": {
"required": true,
"notification_type": "source_message_review",
"show_source_message": true,
"requires_user_decision": true,
"visible_message": "无法形成业务素材包,请人工查看原邮件。"
},
"manual_review": {
"reason_code": "material_package_unavailable",
"visible_reason": "邮件内容无法形成业务素材包。",
"review_record_type": "main_agent_entry_review",
"missing_fields": [],
"blocking_points": [],
"conflicting_points": [],
"suggested_human_actions": [],
"evidence_to_check": [],
"known_fields": {}
}
}
""".formatted(externalMessageId);
}
private String parentSplitBusinessRoot(String externalMessageId) {
return """
{