实现 SuperAgent 查询上下文接口

This commit is contained in:
andy
2026-07-07 19:56:49 +08:00
parent bd7b791bee
commit 652c5c10c5
19 changed files with 2066 additions and 4 deletions

View File

@@ -0,0 +1,43 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.time.LocalDateTime;
/**
* SuperAgent 查询接口使用的订单快照。该 DTO 比普通订单快照包含更多只读展示和终止字段。
*
* @param id 本系统订单主键
* @param hotelId 酒店上下文 ID
* @param orderKeyType 订单业务号类型,例如 GROUP_CODE、CONFIRMATION_NUMBER、TEMPORARY
* @param orderBusinessKey 原始订单业务号
* @param activeBusinessKey 当前生效业务号
* @param temporaryOrderCode 临时订单号
* @param orderStatus 订单状态
* @param businessKeySource 业务号来源
* @param displayName 前端和 Skill 可读展示名
* @param sourceMessageId 订单来源 SourceMessage ID
* @param createdFromTaskId 创建该订单的任务 ID
* @param endedAt 订单结束时间
* @param logicDeletedAt 订单逻辑删除时间
* @param logicDeletedReason 订单逻辑删除原因
* @param createdAt 创建时间
* @param updatedAt 最近更新时间
*/
public record ReservationAiQueryOrderSnapshot(
Long id,
String hotelId,
String orderKeyType,
String orderBusinessKey,
String activeBusinessKey,
String temporaryOrderCode,
String orderStatus,
String businessKeySource,
String displayName,
Long sourceMessageId,
Long createdFromTaskId,
LocalDateTime endedAt,
LocalDateTime logicDeletedAt,
String logicDeletedReason,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,57 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.time.LocalDateTime;
/**
* SuperAgent 查询接口使用的任务快照。包含任务队列字段和对应 AI transition 的路由字段。
*
* @param id 本系统任务主键
* @param hotelId 酒店上下文 ID
* @param orderId 任务当前挂靠订单 ID
* @param sourceMessageId 任务来源 SourceMessage ID
* @param aiTransitionId 对应 AI transition ID
* @param resultType AI 结果类型
* @param aiTaskType AI 原始任务类型
* @param systemTaskType 系统主任务类型
* @param taskCardType 前端任务卡类型
* @param taskSubtype 业务动作 subtype
* @param taskStatus 任务状态
* @param queueParticipation 是否参与订单执行队列
* @param executionOrder 同订单执行顺序
* @param parentTaskId 父任务 ID
* @param parentSourceEventIndex 父事件序号
* @param linkedTaskGroupId 联动任务组 ID
* @param blockedUntilParentCompleted 是否等待父任务完成
* @param lastFailureReason 最近失败原因
* @param completedAt 任务完成时间
* @param updatedAt 最近更新时间
* @param transitionSourceEventIndex AI transition 来源事件序号
* @param catalogCode Skill 目录代码
* @param skillId Skill 标识
*/
public record ReservationAiQueryTaskSnapshot(
Long id,
String hotelId,
Long orderId,
Long sourceMessageId,
Long aiTransitionId,
String resultType,
String aiTaskType,
String systemTaskType,
String taskCardType,
String taskSubtype,
String taskStatus,
Boolean queueParticipation,
Integer executionOrder,
Long parentTaskId,
Integer parentSourceEventIndex,
String linkedTaskGroupId,
Boolean blockedUntilParentCompleted,
String lastFailureReason,
LocalDateTime completedAt,
LocalDateTime updatedAt,
Integer transitionSourceEventIndex,
String catalogCode,
String skillId
) {
}

View File

@@ -0,0 +1,38 @@
package cn.nianxx.thhotel.workflows.reservation.common.request;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* SuperAgent 查询订单上下文请求。该请求只用于只读查询,不触发任务创建或 OPERA 写入。
*
* @param hotelId 酒店上下文 ID用于隔离订单、任务和 AI transition 数据
* @param sourceMessageId 当前 SourceMessage ID外部以字符串传入避免长整型精度问题
* @param sourceEventIndex 当前 AI 事件序号,用于和拆分结果保持一致
* @param groupCode Group Code / Allotment Code 查询 key
* @param confirmationNumber Confirmation Number 查询 key
* @param reservationNo OPERA reservation no第一版无可靠表源仅参与入参完整性校验
* @param objectTypeHint 调用方推测的对象类型,只作为提示不作为业务事实
* @param targetKeySource key 来源,例如 body_current 或 body_thread_evidence
* @param bodyThreadUsedOnlyAsEvidence 历史线程 key 是否仅作为证据使用
*/
public record ReservationAiCaseContextQueryRequest(
@JsonProperty("hotel_id")
String hotelId,
@JsonProperty("source_message_id")
String sourceMessageId,
@JsonProperty("source_event_index")
Integer sourceEventIndex,
@JsonProperty("group_code")
String groupCode,
@JsonProperty("confirmation_number")
String confirmationNumber,
@JsonProperty("reservation_no")
String reservationNo,
@JsonProperty("object_type_hint")
String objectTypeHint,
@JsonProperty("target_key_source")
String targetKeySource,
@JsonProperty("body_thread_used_only_as_evidence")
Boolean bodyThreadUsedOnlyAsEvidence
) {
}

View File

@@ -0,0 +1,20 @@
package cn.nianxx.thhotel.workflows.reservation.common.request;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* SuperAgent 查询对象详情请求。第一版仅支持通过 ORDER:{orderId} 查询本系统订单快照。
*
* @param hotelId 酒店上下文 ID用于隔离订单和任务数据
* @param objectId 查询对象 ID第一版格式为 ORDER:{orderId}
* @param objectType 调用方传入的对象类型提示,第一版不作为强校验条件
*/
public record ReservationAiObjectDetailQueryRequest(
@JsonProperty("hotel_id")
String hotelId,
@JsonProperty("object_id")
String objectId,
@JsonProperty("object_type")
String objectType
) {
}

View File

@@ -0,0 +1,217 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDateTime;
import java.util.List;
/**
* query_case_context 返回数据。只表达订单、任务和终止事实,不输出最终业务结论。
*
* @param matchedOrderRecords 按查询 key 匹配到的订单摘要
* @param pendingOrOpenTasks 同 key 或同订单下尚未结束的任务摘要
* @param activeWorkflows 活跃工作流摘要,第一版无独立 workflow 表所以固定为空列表
* @param terminatedRecords 影响 Skill 判断的终止订单或终止任务事实
* @param targetObjectValidation 后端基于事实给出的目标对象校验摘要
* @param keyRelationships Group Code 与 Confirmation Number 的关系证据
*/
public record ReservationAiCaseContextResult(
@JsonProperty("matched_order_records")
List<MatchedOrderRecord> matchedOrderRecords,
@JsonProperty("pending_or_open_tasks")
List<PendingOrOpenTask> pendingOrOpenTasks,
@JsonProperty("active_workflows")
List<ActiveWorkflow> activeWorkflows,
@JsonProperty("terminated_records")
List<TerminatedRecord> terminatedRecords,
@JsonProperty("target_object_validation")
TargetObjectValidation targetObjectValidation,
@JsonProperty("key_relationships")
KeyRelationships keyRelationships
) {
/**
* 订单匹配摘要。object_id 使用 ORDER:{orderId},便于 object-detail 接口继续查询。
*
* @param objectId 查询对象 ID
* @param objectType 查询对象类型
* @param orderId 本系统订单 ID
* @param orderKeyType 订单业务号类型
* @param groupCode Group Code
* @param confirmationNumber Confirmation Number
* @param temporaryOrderCode 临时订单号
* @param displayName 用户可读展示名
* @param status 订单状态
* @param businessKeySource 业务号来源
* @param sourceTable 来源表名
* @param lastUpdatedAt 最近更新时间
*/
public record MatchedOrderRecord(
@JsonProperty("object_id")
String objectId,
@JsonProperty("object_type")
String objectType,
@JsonProperty("order_id")
String orderId,
@JsonProperty("order_key_type")
String orderKeyType,
@JsonProperty("group_code")
String groupCode,
@JsonProperty("confirmation_number")
String confirmationNumber,
@JsonProperty("temporary_order_code")
String temporaryOrderCode,
@JsonProperty("display_name")
String displayName,
String status,
@JsonProperty("business_key_source")
String businessKeySource,
@JsonProperty("source_table")
String sourceTable,
@JsonProperty("last_updated_at")
LocalDateTime lastUpdatedAt
) {
}
/**
* 未完成任务摘要。FAILED 和 COMPLETED 不进入该列表,避免误阻塞后续任务。
*
* @param taskId 本系统任务 ID
* @param orderId 任务当前挂靠订单 ID
* @param sourceMessageId 任务来源消息 ID
* @param sourceEventIndex AI current 事件序号
* @param catalogCode Skill 目录代码
* @param skillId Skill 标识
* @param resultType AI 结果类型
* @param taskType AI 原始任务类型
* @param systemTaskType 系统主任务类型
* @param taskCardType 任务卡类型
* @param taskSubtype 业务动作 subtype
* @param taskStatus 任务状态
* @param queueParticipation 是否参与订单执行队列
* @param executionOrder 同订单执行顺序
* @param parentTaskId 父任务 ID
* @param parentSourceEventIndex 父事件序号
* @param linkedTaskGroupId 联动任务组 ID
* @param blockedUntilParentCompleted 是否等待父任务完成
* @param lastUpdatedAt 最近更新时间
*/
public record PendingOrOpenTask(
@JsonProperty("task_id")
String taskId,
@JsonProperty("order_id")
String orderId,
@JsonProperty("source_message_id")
String sourceMessageId,
@JsonProperty("source_event_index")
Integer sourceEventIndex,
@JsonProperty("catalog_code")
String catalogCode,
@JsonProperty("skill_id")
String skillId,
@JsonProperty("result_type")
String resultType,
@JsonProperty("task_type")
String taskType,
@JsonProperty("system_task_type")
String systemTaskType,
@JsonProperty("task_card_type")
String taskCardType,
@JsonProperty("task_subtype")
String taskSubtype,
@JsonProperty("task_status")
String taskStatus,
@JsonProperty("queue_participation")
Boolean queueParticipation,
@JsonProperty("execution_order")
Integer executionOrder,
@JsonProperty("parent_task_id")
String parentTaskId,
@JsonProperty("parent_source_event_index")
Integer parentSourceEventIndex,
@JsonProperty("linked_task_group_id")
String linkedTaskGroupId,
@JsonProperty("blocked_until_parent_completed")
Boolean blockedUntilParentCompleted,
@JsonProperty("last_updated_at")
LocalDateTime lastUpdatedAt
) {
}
/**
* 活跃工作流占位结构。当前系统没有独立 workflow 表,所以接口只返回空列表。
*/
public record ActiveWorkflow() {
}
/**
* 终止事实摘要。用于让 Skill 看到 ENDED、LOGIC_DELETED、FAILED 和 COMPLETED。
*
* @param recordType 终止事实类型order 或 task
* @param recordId 终止记录 ID
* @param status 终止状态
* @param reason 终止原因
* @param occurredAt 终止发生时间
* @param lastUpdatedAt 最近更新时间
*/
public record TerminatedRecord(
@JsonProperty("record_type")
String recordType,
@JsonProperty("record_id")
String recordId,
String status,
String reason,
@JsonProperty("occurred_at")
LocalDateTime occurredAt,
@JsonProperty("last_updated_at")
LocalDateTime lastUpdatedAt
) {
}
/**
* 目标对象校验摘要。字段名保持导入契约语义,但不替代 Skill 的最终判断。
*
* @param status 校验状态none、single、multiple 或 conflict
* @param matchedObjectId 唯一匹配对象 ID
* @param matchedObjectType 唯一匹配对象类型
* @param canCreateNewBookingTask 是否允许继续创建 New Booking 任务
* @param canCreateUpdateTask 是否允许创建 Update Booking 任务
* @param canCreateCancelTask 是否允许创建 Cancel Booking 任务
* @param canAttachVoucher 是否允许绑定 voucher 类任务
* @param canAttachRoomingList 是否允许绑定 rooming list 类任务
* @param needsManualReviewReason 需要人工复核的原因码
*/
public record TargetObjectValidation(
String status,
@JsonProperty("matched_object_id")
String matchedObjectId,
@JsonProperty("matched_object_type")
String matchedObjectType,
@JsonProperty("can_create_new_booking_task")
Boolean canCreateNewBookingTask,
@JsonProperty("can_create_update_task")
Boolean canCreateUpdateTask,
@JsonProperty("can_create_cancel_task")
Boolean canCreateCancelTask,
@JsonProperty("can_attach_voucher")
Boolean canAttachVoucher,
@JsonProperty("can_attach_rooming_list")
Boolean canAttachRoomingList,
@JsonProperty("needs_manual_review_reason")
String needsManualReviewReason
) {
}
/**
* 业务 key 关系。当前系统没有关系投影,多数场景保持 null。
*
* @param groupCodeAndConfirmationSameObject 是否可证明两个 key 属于同一对象
* @param relationshipEvidence 关系判断证据说明
*/
public record KeyRelationships(
@JsonProperty("group_code_and_confirmation_same_object")
Boolean groupCodeAndConfirmationSameObject,
@JsonProperty("relationship_evidence")
String relationshipEvidence
) {
}
}

View File

@@ -0,0 +1,115 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDateTime;
import java.util.List;
/**
* query_object_detail 返回数据。第一版以本系统订单快照为主OPERA 投影字段允许为空。
*
* @param objectId 查询对象 ID
* @param objectType 查询对象类型
* @param orderId 本系统订单 ID
* @param orderKeyType 订单业务号类型
* @param groupCode Group Code
* @param confirmationNumber Confirmation Number
* @param reservationNo OPERA reservation no当前无可靠来源时为空
* @param blockId OPERA block id当前无可靠来源时为空
* @param temporaryOrderCode 临时订单号
* @param displayName 用户可读展示名
* @param status 订单状态
* @param sourceMessageId 来源 SourceMessage ID
* @param createdFromTaskId 创建该订单的任务 ID
* @param createdAt 创建时间
* @param lastUpdatedAt 最近更新时间
* @param arrivalDate 到店日期,当前无可靠来源时为空
* @param departureDate 离店日期,当前无可靠来源时为空
* @param nights 间夜数,当前无可靠来源时为空
* @param guestCount 客人数,当前无可靠来源时为空
* @param roomItems 房型明细,当前无可靠来源时为空数组
* @param rateCode 房价代码,当前无可靠来源时为空
* @param rateCodePrice 房价,当前无可靠来源时为空
* @param reservationType 预订类型,当前无可靠来源时为空
* @param cancelStatus 取消状态摘要
* @param canUpdate 是否允许继续创建更新类任务
* @param canCancel 是否允许继续创建取消类任务
* @param hardValidationWarnings 对象详情硬校验缺口警告
*/
public record ReservationAiObjectDetailResult(
@JsonProperty("object_id")
String objectId,
@JsonProperty("object_type")
String objectType,
@JsonProperty("order_id")
String orderId,
@JsonProperty("order_key_type")
String orderKeyType,
@JsonProperty("group_code")
String groupCode,
@JsonProperty("confirmation_number")
String confirmationNumber,
@JsonProperty("reservation_no")
String reservationNo,
@JsonProperty("block_id")
String blockId,
@JsonProperty("temporary_order_code")
String temporaryOrderCode,
@JsonProperty("display_name")
String displayName,
String status,
@JsonProperty("source_message_id")
String sourceMessageId,
@JsonProperty("created_from_task_id")
String createdFromTaskId,
@JsonProperty("created_at")
LocalDateTime createdAt,
@JsonProperty("last_updated_at")
LocalDateTime lastUpdatedAt,
@JsonProperty("arrival_date")
String arrivalDate,
@JsonProperty("departure_date")
String departureDate,
Integer nights,
@JsonProperty("guest_count")
Integer guestCount,
@JsonProperty("room_items")
List<RoomItem> roomItems,
@JsonProperty("rate_code")
String rateCode,
@JsonProperty("rate_code_price")
String rateCodePrice,
@JsonProperty("reservation_type")
String reservationType,
@JsonProperty("cancel_status")
String cancelStatus,
@JsonProperty("can_update")
Boolean canUpdate,
@JsonProperty("can_cancel")
Boolean canCancel,
@JsonProperty("hard_validation_warnings")
List<ReservationAiQueryWarningResult> hardValidationWarnings
) {
/**
* 房型明细摘要。当前系统没有 OPERA 投影时返回空数组。
*
* @param roomType 房型展示名
* @param pmsRoomTypeCode PMS 房型代码
* @param roomQuantity 房间数量
* @param rateCode 房价代码
* @param rateCodePrice 房价
*/
public record RoomItem(
@JsonProperty("room_type")
String roomType,
@JsonProperty("pms_room_type_code")
String pmsRoomTypeCode,
@JsonProperty("room_quantity")
Integer roomQuantity,
@JsonProperty("rate_code")
String rateCode,
@JsonProperty("rate_code_price")
String rateCodePrice
) {
}
}

View File

@@ -0,0 +1,17 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import java.util.Map;
/**
* SuperAgent 查询接口错误项。错误内容不回显邮件正文、附件地址或 Secret。
*
* @param code 稳定错误码
* @param message 用户或调用方可读错误说明
* @param details 结构化错误明细,不能包含敏感信息
*/
public record ReservationAiQueryErrorResult(
String code,
String message,
Map<String, Object> details
) {
}

View File

@@ -0,0 +1,47 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* SuperAgent 查询接口统一响应包。成功和失败都使用该结构,便于 Skill 侧稳定解析。
*
* @param success 是否查询成功
* @param requestId 调用方请求 ID
* @param traceId AI 运行链路 ID
* @param data 成功响应数据
* @param warnings 非阻断警告列表
* @param error 失败响应错误项
*/
public record ReservationAiQueryResponse<T>(
boolean success,
@JsonProperty("request_id")
String requestId,
@JsonProperty("trace_id")
String traceId,
T data,
List<ReservationAiQueryWarningResult> warnings,
ReservationAiQueryErrorResult error
) {
/**
* 构造成功响应warnings 允许为空列表但不返回 null。
*/
public static <T> ReservationAiQueryResponse<T> success(
String requestId,
String traceId,
T data,
List<ReservationAiQueryWarningResult> warnings) {
return new ReservationAiQueryResponse<>(true, requestId, traceId, data, warnings == null ? List.of() : warnings, null);
}
/**
* 构造失败响应data 固定为空,避免调用方误读部分结果。
*/
public static <T> ReservationAiQueryResponse<T> failure(
String requestId,
String traceId,
ReservationAiQueryErrorResult error) {
return new ReservationAiQueryResponse<>(false, requestId, traceId, null, List.of(), error);
}
}

View File

@@ -0,0 +1,13 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
/**
* SuperAgent 查询接口警告项。用于说明当前系统无法确认但不应导致查询失败的事实缺口。
*
* @param code 稳定警告码
* @param message 警告说明
*/
public record ReservationAiQueryWarningResult(
String code,
String message
) {
}

View File

@@ -0,0 +1,62 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjectDetailQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiCaseContextResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiObjectDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryResponse;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiQueryService;
import java.util.List;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* SuperAgent 只读查询 Controller。提供订单上下文和对象详情不创建任务、不写 OPERA。
*/
@RestController
@RequestMapping("/api/ai-query/v1")
public class ReservationAiQueryController {
private final ReservationAiQueryService aiQueryService;
/**
* 注入只读查询服务Controller 只负责请求响应契约映射。
*/
public ReservationAiQueryController(ReservationAiQueryService aiQueryService) {
this.aiQueryService = aiQueryService;
}
/**
* 查询订单路由上下文,返回匹配订单、未完成任务、终止记录和目标对象校验摘要。
*/
@PostMapping(
value = "/case-context",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationAiQueryResponse<ReservationAiCaseContextResult> queryCaseContext(
@RequestHeader("X-Request-Id") String requestId,
@RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId,
@RequestBody(required = false) ReservationAiCaseContextQueryRequest request) {
ReservationAiCaseContextResult result = aiQueryService.queryCaseContext(request);
return ReservationAiQueryResponse.success(requestId, traceId, result, List.of());
}
/**
* 查询对象详情。第一版只支持 ORDER:{orderId}OPERA 投影缺口以 warning 表达。
*/
@PostMapping(
value = "/object-detail",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationAiQueryResponse<ReservationAiObjectDetailResult> queryObjectDetail(
@RequestHeader("X-Request-Id") String requestId,
@RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId,
@RequestBody(required = false) ReservationAiObjectDetailQueryRequest request) {
ReservationAiObjectDetailResult result = aiQueryService.queryObjectDetail(request);
return ReservationAiQueryResponse.success(requestId, traceId, result, List.of());
}
}

View File

@@ -0,0 +1,52 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryErrorResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryResponse;
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiQueryException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* SuperAgent 查询接口异常处理。统一返回 success=false不暴露内部堆栈或敏感上下文。
*/
@RestControllerAdvice(assignableTypes = ReservationAiQueryController.class)
public class ReservationAiQueryControllerAdvice {
/**
* 处理查询服务抛出的受控参数错误和对象不存在错误。
*/
@ExceptionHandler(ReservationAiQueryException.class)
public ResponseEntity<ReservationAiQueryResponse<Object>> handleAiQueryException(
ReservationAiQueryException exception,
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId) {
return ResponseEntity.status(exception.getStatus())
.body(ReservationAiQueryResponse.failure(
requestId,
traceId,
new ReservationAiQueryErrorResult(
exception.getErrorCode(),
exception.getMessage(),
exception.getDetails())));
}
/**
* 处理缺少 X-Request-Id 等必要请求头的错误。
*/
@ExceptionHandler(MissingRequestHeaderException.class)
public ResponseEntity<ReservationAiQueryResponse<Object>> handleMissingHeader(
MissingRequestHeaderException exception,
@RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId) {
return ResponseEntity.badRequest()
.body(ReservationAiQueryResponse.failure(
null,
traceId,
new ReservationAiQueryErrorResult(
"REQUEST_HEADER_REQUIRED",
exception.getHeaderName() + " 请求头不能为空",
java.util.Map.of())));
}
}

View File

@@ -2,6 +2,8 @@ package cn.nianxx.thhotel.workflows.reservation.repository;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryOrderSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogSnapshot;
@@ -15,6 +17,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardDra
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationAiBatchEntity;
@@ -35,7 +38,10 @@ import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationTaskCardMapper;
import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationTaskMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Repository;
@@ -300,6 +306,92 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
return Optional.ofNullable(entity).map(this::toOrderSnapshot);
}
/**
* 按业务 key 查询订单上下文。包含 ACTIVE 和已终止订单,供 Skill 判断冲突或终止事实。
*/
@Override
public List<ReservationAiQueryOrderSnapshot> findAiQueryOrdersByBusinessKeys(
String hotelId,
String groupCode,
String confirmationNumber) {
Map<Long, ReservationAiQueryOrderSnapshot> orders = new LinkedHashMap<>();
if (hasText(groupCode)) {
orderMapper.selectList(Wrappers.<ReservationOrderEntity>lambdaQuery()
.eq(ReservationOrderEntity::getHotelId, hotelId)
.eq(ReservationOrderEntity::getOrderKeyType, ReservationOrderKeyType.GROUP_CODE.name())
.and(wrapper -> wrapper
.eq(ReservationOrderEntity::getActiveBusinessKey, groupCode)
.or()
.eq(ReservationOrderEntity::getOrderBusinessKey, groupCode))
.orderByDesc(ReservationOrderEntity::getUpdatedAt))
.forEach(entity -> orders.put(entity.getId(), toAiQueryOrderSnapshot(entity)));
}
if (hasText(confirmationNumber)) {
orderMapper.selectList(Wrappers.<ReservationOrderEntity>lambdaQuery()
.eq(ReservationOrderEntity::getHotelId, hotelId)
.eq(ReservationOrderEntity::getOrderKeyType, ReservationOrderKeyType.CONFIRMATION_NUMBER.name())
.and(wrapper -> wrapper
.eq(ReservationOrderEntity::getActiveBusinessKey, confirmationNumber)
.or()
.eq(ReservationOrderEntity::getOrderBusinessKey, confirmationNumber))
.orderByDesc(ReservationOrderEntity::getUpdatedAt))
.forEach(entity -> orders.put(entity.getId(), toAiQueryOrderSnapshot(entity)));
}
return List.copyOf(orders.values());
}
/**
* 按订单 ID 查询 AI 查询接口使用的订单快照。
*/
@Override
public Optional<ReservationAiQueryOrderSnapshot> findAiQueryOrderById(String hotelId, Long orderId) {
ReservationOrderEntity entity = orderMapper.selectOne(Wrappers.<ReservationOrderEntity>lambdaQuery()
.eq(ReservationOrderEntity::getHotelId, hotelId)
.eq(ReservationOrderEntity::getId, orderId)
.last("LIMIT 1"));
return Optional.ofNullable(entity).map(this::toAiQueryOrderSnapshot);
}
/**
* 查询指定订单下全部任务,并补充对应 AI transition 的事件序号和 Skill 信息。
*/
@Override
public List<ReservationAiQueryTaskSnapshot> findAiQueryTasksByOrderIds(String hotelId, List<Long> orderIds) {
if (orderIds == null || orderIds.isEmpty()) {
return List.of();
}
List<ReservationTaskEntity> tasks = taskMapper.selectList(Wrappers.<ReservationTaskEntity>lambdaQuery()
.eq(ReservationTaskEntity::getHotelId, hotelId)
.in(ReservationTaskEntity::getOrderId, orderIds)
.orderByAsc(ReservationTaskEntity::getOrderId)
.orderByAsc(ReservationTaskEntity::getExecutionOrder));
return toAiQueryTaskSnapshots(hotelId, tasks);
}
/**
* 按 AI transition 中冗余的 Group Code 或 Confirmation Number 查询任务。
*/
@Override
public List<ReservationAiQueryTaskSnapshot> findAiQueryTasksByBusinessKeys(
String hotelId,
String groupCode,
String confirmationNumber) {
List<ReservationAiTransitionEntity> transitions = findAiQueryTransitionsByBusinessKeys(
hotelId,
groupCode,
confirmationNumber);
if (transitions.isEmpty()) {
return List.of();
}
List<Long> transitionIds = transitions.stream().map(ReservationAiTransitionEntity::getId).toList();
List<ReservationTaskEntity> tasks = taskMapper.selectList(Wrappers.<ReservationTaskEntity>lambdaQuery()
.eq(ReservationTaskEntity::getHotelId, hotelId)
.in(ReservationTaskEntity::getAiTransitionId, transitionIds)
.orderByAsc(ReservationTaskEntity::getOrderId)
.orderByAsc(ReservationTaskEntity::getExecutionOrder));
return toAiQueryTaskSnapshots(hotelId, tasks);
}
/**
* 将临时订单激活为有真实业务号的 ACTIVE 订单。唯一约束冲突由 Service 转换为业务错误。
*/
@@ -580,6 +672,72 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
.toList();
}
/**
* 按业务 key 查询 AI transition。该查询只用于补充待处理任务上下文不替代订单查询。
*/
private List<ReservationAiTransitionEntity> findAiQueryTransitionsByBusinessKeys(
String hotelId,
String groupCode,
String confirmationNumber) {
if (!hasText(groupCode) && !hasText(confirmationNumber)) {
return List.of();
}
return transitionMapper.selectList(Wrappers.<ReservationAiTransitionEntity>lambdaQuery()
.eq(ReservationAiTransitionEntity::getHotelId, hotelId)
.and(wrapper -> {
boolean hasPrevious = false;
if (hasText(groupCode)) {
wrapper.eq(ReservationAiTransitionEntity::getGroupCode, groupCode);
hasPrevious = true;
}
if (hasText(confirmationNumber)) {
if (hasPrevious) {
wrapper.or();
}
wrapper.eq(ReservationAiTransitionEntity::getConfirmationNumber, confirmationNumber);
}
}));
}
/**
* 将任务实体批量转换为 AI 查询任务快照,补充 transition 中的 Skill 元数据。
*/
private List<ReservationAiQueryTaskSnapshot> toAiQueryTaskSnapshots(
String hotelId,
List<ReservationTaskEntity> tasks) {
if (tasks == null || tasks.isEmpty()) {
return List.of();
}
Map<Long, ReservationAiTransitionEntity> transitions = findTransitionsByIds(
hotelId,
tasks.stream().map(ReservationTaskEntity::getAiTransitionId).toList());
return tasks.stream()
.map(task -> toAiQueryTaskSnapshot(task, transitions.get(task.getAiTransitionId())))
.toList();
}
/**
* 按 transition ID 批量查询,并按 ID 建立索引。
*/
private Map<Long, ReservationAiTransitionEntity> findTransitionsByIds(String hotelId, Collection<Long> transitionIds) {
if (transitionIds == null || transitionIds.isEmpty()) {
return Map.of();
}
Map<Long, ReservationAiTransitionEntity> result = new LinkedHashMap<>();
transitionMapper.selectList(Wrappers.<ReservationAiTransitionEntity>lambdaQuery()
.eq(ReservationAiTransitionEntity::getHotelId, hotelId)
.in(ReservationAiTransitionEntity::getId, transitionIds))
.forEach(entity -> result.put(entity.getId(), entity));
return result;
}
/**
* 判断文本是否可参与查询条件。
*/
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
/**
* 转换批次实体为快照。
*/
@@ -607,6 +765,29 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
entity.getOrderStatus());
}
/**
* 转换订单实体为 AI 查询订单快照。
*/
private ReservationAiQueryOrderSnapshot toAiQueryOrderSnapshot(ReservationOrderEntity entity) {
return new ReservationAiQueryOrderSnapshot(
entity.getId(),
entity.getHotelId(),
entity.getOrderKeyType(),
entity.getOrderBusinessKey(),
entity.getActiveBusinessKey(),
entity.getTemporaryOrderCode(),
entity.getOrderStatus(),
entity.getBusinessKeySource(),
entity.getDisplayName(),
entity.getSourceMessageId(),
entity.getCreatedFromTaskId(),
entity.getEndedAt(),
entity.getLogicDeletedAt(),
entity.getLogicDeletedReason(),
entity.getCreatedAt(),
entity.getUpdatedAt());
}
/**
* 转换任务实体为快照。
*/
@@ -632,6 +813,38 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
entity.getConfirmedAt());
}
/**
* 转换任务实体为 AI 查询任务快照transition 为空时仍返回任务自身事实。
*/
private ReservationAiQueryTaskSnapshot toAiQueryTaskSnapshot(
ReservationTaskEntity entity,
ReservationAiTransitionEntity transition) {
return new ReservationAiQueryTaskSnapshot(
entity.getId(),
entity.getHotelId(),
entity.getOrderId(),
entity.getSourceMessageId(),
entity.getAiTransitionId(),
entity.getResultType(),
entity.getAiTaskType(),
entity.getSystemTaskType(),
entity.getTaskCardType(),
entity.getTaskSubtype(),
entity.getTaskStatus(),
entity.getQueueParticipation(),
entity.getExecutionOrder(),
entity.getParentTaskId(),
entity.getParentSourceEventIndex(),
entity.getLinkedTaskGroupId(),
entity.getBlockedUntilParentCompleted(),
entity.getLastFailureReason(),
entity.getCompletedAt(),
entity.getUpdatedAt(),
transition == null ? null : transition.getSourceEventIndex(),
transition == null ? null : transition.getCatalogCode(),
transition == null ? null : transition.getSkillId());
}
/**
* 转换任务卡实体为快照。
*/

View File

@@ -2,6 +2,8 @@ package cn.nianxx.thhotel.workflows.reservation.repository;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryOrderSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogSnapshot;
@@ -87,6 +89,32 @@ public interface ReservationAiWorkflowRepository {
*/
Optional<ReservationOrderSnapshot> findOrderById(String hotelId, Long orderId);
/**
* 按 SuperAgent 查询 key 查找订单上下文,包含 ACTIVE、ENDED、LOGIC_DELETED 和 TEMPORARY。
*/
List<ReservationAiQueryOrderSnapshot> findAiQueryOrdersByBusinessKeys(
String hotelId,
String groupCode,
String confirmationNumber);
/**
* 按订单 ID 查询 SuperAgent 查询接口使用的订单详情快照。
*/
Optional<ReservationAiQueryOrderSnapshot> findAiQueryOrderById(String hotelId, Long orderId);
/**
* 查询指定订单下全部任务,并带上对应 AI transition 的路由字段。
*/
List<ReservationAiQueryTaskSnapshot> findAiQueryTasksByOrderIds(String hotelId, List<Long> orderIds);
/**
* 按 AI transition 冗余业务 key 查询任务,用于查到待处理任务但尚无正式订单的场景。
*/
List<ReservationAiQueryTaskSnapshot> findAiQueryTasksByBusinessKeys(
String hotelId,
String groupCode,
String confirmationNumber);
/**
* 将临时订单激活为带真实业务号的 ACTIVE 订单。
*/

View File

@@ -0,0 +1,22 @@
package cn.nianxx.thhotel.workflows.reservation.service;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjectDetailQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiCaseContextResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiObjectDetailResult;
/**
* Reservation AI 查询服务。为 SuperAgent / Main Agent 提供只读上下文,不产生业务写入。
*/
public interface ReservationAiQueryService {
/**
* 查询订单路由上下文,返回订单、未完成任务、终止记录和目标校验摘要。
*/
ReservationAiCaseContextResult queryCaseContext(ReservationAiCaseContextQueryRequest request);
/**
* 查询单个对象详情。第一版只支持 ORDER:{orderId} 形式的本系统订单对象。
*/
ReservationAiObjectDetailResult queryObjectDetail(ReservationAiObjectDetailQueryRequest request);
}

View File

@@ -0,0 +1,41 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import java.util.Map;
import org.springframework.http.HttpStatus;
/**
* SuperAgent 查询接口受控异常。用于把参数错误和对象不存在转换为稳定 JSON 响应。
*/
public class ReservationAiQueryException extends RuntimeException {
private final HttpStatus status;
private final String errorCode;
private final Map<String, Object> details;
public ReservationAiQueryException(HttpStatus status, String errorCode, String message) {
this(status, errorCode, message, Map.of());
}
public ReservationAiQueryException(
HttpStatus status,
String errorCode,
String message,
Map<String, Object> details) {
super(message);
this.status = status;
this.errorCode = errorCode;
this.details = details == null ? Map.of() : details;
}
public HttpStatus getStatus() {
return status;
}
public String getErrorCode() {
return errorCode;
}
public Map<String, Object> getDetails() {
return details;
}
}

View File

@@ -0,0 +1,394 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryOrderSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationSystemTaskType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjectDetailQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiCaseContextResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiObjectDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryWarningResult;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiQueryService;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
/**
* Reservation AI 查询服务实现。只读取订单、任务和 AI 过渡层事实,不执行业务状态流转。
*/
@Service
public class ReservationAiQueryServiceImpl implements ReservationAiQueryService {
private static final String OBJECT_ID_PREFIX_ORDER = "ORDER:";
private static final String SOURCE_TABLE_ORDER = "workflow_reservation_order";
private static final String WARNING_OPERA_PROJECTION_UNAVAILABLE = "OPERA_PROJECTION_UNAVAILABLE";
private static final Set<String> OPEN_TASK_STATUSES = Set.of(
ReservationTaskStatus.PENDING_CONFIRM.name(),
ReservationTaskStatus.READY.name(),
ReservationTaskStatus.EXECUTING.name());
private static final Set<String> TERMINATED_TASK_STATUSES = Set.of(
ReservationTaskStatus.FAILED.name(),
ReservationTaskStatus.COMPLETED.name());
private final ReservationAiWorkflowRepository repository;
/**
* 注入 Reservation 工作流持久化边界Service 不直接依赖 Mapper。
*/
public ReservationAiQueryServiceImpl(ReservationAiWorkflowRepository repository) {
this.repository = repository;
}
/**
* 查询订单上下文。按业务 key 查订单,并合并同订单任务与同 key AI transition 任务。
*/
@Override
public ReservationAiCaseContextResult queryCaseContext(ReservationAiCaseContextQueryRequest request) {
validateCaseContextRequest(request);
String hotelId = trimToNull(request.hotelId());
String groupCode = trimToNull(request.groupCode());
String confirmationNumber = trimToNull(request.confirmationNumber());
if (groupCode == null && confirmationNumber == null) {
return unsupportedReservationNoOnlyResult();
}
List<ReservationAiQueryOrderSnapshot> orders = repository.findAiQueryOrdersByBusinessKeys(
hotelId,
groupCode,
confirmationNumber);
List<Long> orderIds = orders.stream().map(ReservationAiQueryOrderSnapshot::id).toList();
List<ReservationAiQueryTaskSnapshot> tasks = mergeTasks(
repository.findAiQueryTasksByOrderIds(hotelId, orderIds),
repository.findAiQueryTasksByBusinessKeys(hotelId, groupCode, confirmationNumber));
List<ReservationAiCaseContextResult.PendingOrOpenTask> openTasks = tasks.stream()
.filter(task -> OPEN_TASK_STATUSES.contains(task.taskStatus()))
.sorted(Comparator
.comparing(ReservationAiQueryTaskSnapshot::orderId, Comparator.nullsLast(Long::compareTo))
.thenComparing(ReservationAiQueryTaskSnapshot::executionOrder, Comparator.nullsLast(Integer::compareTo)))
.map(this::toPendingOrOpenTask)
.toList();
List<ReservationAiCaseContextResult.TerminatedRecord> terminatedRecords = terminatedRecords(orders, tasks);
List<ReservationAiQueryOrderSnapshot> activeOrders = orders.stream()
.filter(order -> ReservationOrderStatus.ACTIVE.name().equals(order.orderStatus()))
.toList();
return new ReservationAiCaseContextResult(
orders.stream().map(this::toMatchedOrderRecord).toList(),
openTasks,
List.of(),
terminatedRecords,
targetObjectValidation(activeOrders, openTasks, terminatedRecords),
new ReservationAiCaseContextResult.KeyRelationships(null, ""));
}
/**
* reservation_no 当前没有可靠表源时,不返回“可创建新任务”的误导性结论。
*/
private ReservationAiCaseContextResult unsupportedReservationNoOnlyResult() {
return new ReservationAiCaseContextResult(
List.of(),
List.of(),
List.of(),
List.of(),
new ReservationAiCaseContextResult.TargetObjectValidation(
"conflict",
null,
null,
false,
false,
false,
false,
false,
"UNSUPPORTED_RESERVATION_NO_QUERY"),
new ReservationAiCaseContextResult.KeyRelationships(null, ""));
}
/**
* 查询对象详情。当前仅解析 ORDER:{orderId}OPERA 投影字段返回 null 和 warning。
*/
@Override
public ReservationAiObjectDetailResult queryObjectDetail(ReservationAiObjectDetailQueryRequest request) {
validateObjectDetailRequest(request);
String hotelId = trimToNull(request.hotelId());
Long orderId = parseOrderObjectId(request.objectId());
ReservationAiQueryOrderSnapshot order = repository.findAiQueryOrderById(hotelId, orderId)
.orElseThrow(() -> new ReservationAiQueryException(
HttpStatus.NOT_FOUND,
"OBJECT_NOT_FOUND",
"查询对象不存在"));
List<ReservationAiQueryTaskSnapshot> tasks = repository.findAiQueryTasksByOrderIds(hotelId, List.of(order.id()));
boolean hasOpenTask = tasks.stream().anyMatch(task -> OPEN_TASK_STATUSES.contains(task.taskStatus()));
boolean active = ReservationOrderStatus.ACTIVE.name().equals(order.orderStatus());
boolean canMutate = active && !hasOpenTask;
return new ReservationAiObjectDetailResult(
objectId(order.id()),
objectType(order),
idString(order.id()),
order.orderKeyType(),
groupCode(order),
confirmationNumber(order),
null,
null,
order.temporaryOrderCode(),
order.displayName(),
order.orderStatus(),
idString(order.sourceMessageId()),
idString(order.createdFromTaskId()),
order.createdAt(),
order.updatedAt(),
null,
null,
null,
null,
List.of(),
null,
null,
null,
cancelStatus(order),
canMutate,
canMutate,
List.of(new ReservationAiQueryWarningResult(
WARNING_OPERA_PROJECTION_UNAVAILABLE,
"当前系统尚未接入 OPERA 对象投影,日期、房型、房价等字段无法确认。")));
}
private ReservationAiCaseContextResult.MatchedOrderRecord toMatchedOrderRecord(
ReservationAiQueryOrderSnapshot order) {
return new ReservationAiCaseContextResult.MatchedOrderRecord(
objectId(order.id()),
objectType(order),
idString(order.id()),
order.orderKeyType(),
groupCode(order),
confirmationNumber(order),
order.temporaryOrderCode(),
order.displayName(),
order.orderStatus(),
order.businessKeySource(),
SOURCE_TABLE_ORDER,
order.updatedAt());
}
private ReservationAiCaseContextResult.PendingOrOpenTask toPendingOrOpenTask(
ReservationAiQueryTaskSnapshot task) {
return new ReservationAiCaseContextResult.PendingOrOpenTask(
idString(task.id()),
idString(task.orderId()),
idString(task.sourceMessageId()),
task.transitionSourceEventIndex(),
task.catalogCode(),
task.skillId(),
task.resultType(),
task.aiTaskType(),
task.systemTaskType(),
task.taskCardType(),
task.taskSubtype(),
task.taskStatus(),
task.queueParticipation(),
task.executionOrder(),
idString(task.parentTaskId()),
task.parentSourceEventIndex(),
task.linkedTaskGroupId(),
task.blockedUntilParentCompleted(),
task.updatedAt());
}
private List<ReservationAiCaseContextResult.TerminatedRecord> terminatedRecords(
List<ReservationAiQueryOrderSnapshot> orders,
List<ReservationAiQueryTaskSnapshot> tasks) {
List<ReservationAiCaseContextResult.TerminatedRecord> orderRecords = orders.stream()
.filter(order -> ReservationOrderStatus.ENDED.name().equals(order.orderStatus())
|| ReservationOrderStatus.LOGIC_DELETED.name().equals(order.orderStatus()))
.map(order -> new ReservationAiCaseContextResult.TerminatedRecord(
"order",
idString(order.id()),
order.orderStatus(),
order.logicDeletedReason(),
terminatedAt(order),
order.updatedAt()))
.toList();
List<ReservationAiCaseContextResult.TerminatedRecord> taskRecords = tasks.stream()
.filter(task -> TERMINATED_TASK_STATUSES.contains(task.taskStatus()))
.map(task -> new ReservationAiCaseContextResult.TerminatedRecord(
"task",
idString(task.id()),
task.taskStatus(),
task.lastFailureReason(),
task.completedAt(),
task.updatedAt()))
.toList();
return Stream.concat(orderRecords.stream(), taskRecords.stream()).toList();
}
private ReservationAiCaseContextResult.TargetObjectValidation targetObjectValidation(
List<ReservationAiQueryOrderSnapshot> activeOrders,
List<ReservationAiCaseContextResult.PendingOrOpenTask> openTasks,
List<ReservationAiCaseContextResult.TerminatedRecord> terminatedRecords) {
boolean hasOpenTask = !openTasks.isEmpty();
boolean hasNewBookingOpenTask = openTasks.stream()
.anyMatch(task -> ReservationSystemTaskType.NEW_BOOKING.name().equals(task.systemTaskType()));
if (activeOrders.size() > 1) {
return new ReservationAiCaseContextResult.TargetObjectValidation(
"multiple",
null,
null,
false,
false,
false,
false,
false,
"MULTIPLE_ACTIVE_OBJECTS");
}
if (activeOrders.size() == 1) {
ReservationAiQueryOrderSnapshot order = activeOrders.get(0);
boolean canOperate = !hasOpenTask;
return new ReservationAiCaseContextResult.TargetObjectValidation(
hasOpenTask ? "conflict" : "single",
objectId(order.id()),
objectType(order),
false,
canOperate,
canOperate,
true,
true,
hasOpenTask ? "OPEN_TASK_EXISTS" : null);
}
return new ReservationAiCaseContextResult.TargetObjectValidation(
terminatedRecords.isEmpty() ? "none" : "conflict",
null,
null,
!hasOpenTask,
false,
false,
hasNewBookingOpenTask,
hasNewBookingOpenTask,
terminatedRecords.isEmpty() ? null : "TERMINATED_RECORD_EXISTS");
}
private List<ReservationAiQueryTaskSnapshot> mergeTasks(
List<ReservationAiQueryTaskSnapshot> first,
List<ReservationAiQueryTaskSnapshot> second) {
Map<Long, ReservationAiQueryTaskSnapshot> merged = new LinkedHashMap<>();
first.forEach(task -> merged.put(task.id(), task));
second.forEach(task -> merged.putIfAbsent(task.id(), task));
return List.copyOf(merged.values());
}
private void validateCaseContextRequest(ReservationAiCaseContextQueryRequest request) {
if (request == null) {
throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空");
}
requireText(request.hotelId(), "HOTEL_ID_REQUIRED", "hotel_id 不能为空");
parseLong(request.sourceMessageId(), "SOURCE_MESSAGE_ID_INVALID", "source_message_id 必须是数字字符串");
if (request.sourceEventIndex() == null || request.sourceEventIndex() <= 0) {
throw badRequest("SOURCE_EVENT_INDEX_INVALID", "source_event_index 必须是正整数");
}
if (trimToNull(request.groupCode()) == null
&& trimToNull(request.confirmationNumber()) == null
&& trimToNull(request.reservationNo()) == null) {
throw badRequest("QUERY_KEY_REQUIRED", "group_code、confirmation_number、reservation_no 至少需要一个");
}
}
private void validateObjectDetailRequest(ReservationAiObjectDetailQueryRequest request) {
if (request == null) {
throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空");
}
requireText(request.hotelId(), "HOTEL_ID_REQUIRED", "hotel_id 不能为空");
requireText(request.objectId(), "OBJECT_ID_REQUIRED", "object_id 不能为空");
}
private ReservationAiQueryException badRequest(String code, String message) {
return new ReservationAiQueryException(HttpStatus.BAD_REQUEST, code, message);
}
private void requireText(String value, String code, String message) {
if (trimToNull(value) == null) {
throw badRequest(code, message);
}
}
private Long parseOrderObjectId(String objectId) {
String text = trimToNull(objectId);
if (text == null || !text.startsWith(OBJECT_ID_PREFIX_ORDER)) {
throw badRequest("OBJECT_ID_INVALID", "object_id 第一版仅支持 ORDER:{orderId}");
}
return parseLong(text.substring(OBJECT_ID_PREFIX_ORDER.length()), "OBJECT_ID_INVALID", "object_id 中的订单 ID 不合法");
}
private Long parseLong(String value, String code, String message) {
try {
return Long.valueOf(Objects.requireNonNull(trimToNull(value)));
} catch (RuntimeException ex) {
throw badRequest(code, message);
}
}
private String objectId(Long orderId) {
return OBJECT_ID_PREFIX_ORDER + orderId;
}
private String objectType(ReservationAiQueryOrderSnapshot order) {
if (ReservationOrderKeyType.GROUP_CODE.name().equals(order.orderKeyType())) {
return "group_block";
}
if (ReservationOrderKeyType.CONFIRMATION_NUMBER.name().equals(order.orderKeyType())) {
return "fit_reservation";
}
return "temporary_order";
}
private String groupCode(ReservationAiQueryOrderSnapshot order) {
if (!ReservationOrderKeyType.GROUP_CODE.name().equals(order.orderKeyType())) {
return null;
}
return Optional.ofNullable(trimToNull(order.activeBusinessKey())).orElse(order.orderBusinessKey());
}
private String confirmationNumber(ReservationAiQueryOrderSnapshot order) {
if (!ReservationOrderKeyType.CONFIRMATION_NUMBER.name().equals(order.orderKeyType())) {
return null;
}
return Optional.ofNullable(trimToNull(order.activeBusinessKey())).orElse(order.orderBusinessKey());
}
private String cancelStatus(ReservationAiQueryOrderSnapshot order) {
if (ReservationOrderStatus.ENDED.name().equals(order.orderStatus())
|| ReservationOrderStatus.LOGIC_DELETED.name().equals(order.orderStatus())) {
return "cancelled";
}
return ReservationOrderStatus.ACTIVE.name().equals(order.orderStatus()) ? "not_cancelled" : "unknown";
}
private LocalDateTime terminatedAt(ReservationAiQueryOrderSnapshot order) {
if (order.logicDeletedAt() != null) {
return order.logicDeletedAt();
}
return order.endedAt();
}
private String idString(Long id) {
return id == null ? null : id.toString();
}
private String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
}

View File

@@ -0,0 +1,227 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.nullValue;
import static org.hamcrest.Matchers.not;
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;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import cn.nianxx.thhotel.ThHotelApplication;
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
import java.time.Instant;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(classes = ThHotelApplication.class)
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ReservationAiQueryControllerTest {
private static final String HOTEL_ID = "HOTEL-TEST";
@Autowired
private MockMvc mockMvc;
@Autowired
private SourceMessageCaptureService captureService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldReturnCaseContextWithMatchedOrderAndPendingTask() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-case-001");
insertActiveGroupOrder(920000000000000101L, source.inboxId(), "GRP-AIQUERY-001");
insertTransition(920000000000000201L, source.inboxId(), 1, "GRP-AIQUERY-001");
insertTask(920000000000000301L, 920000000000000101L, source.inboxId(), 920000000000000201L, "PENDING_CONFIRM");
mockMvc.perform(post("/api/ai-query/v1/case-context")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Request-Id", "req-ai-query-case-001")
.header("X-AI-Trace-Id", "trace-ai-query-case-001")
.content("""
{
"hotel_id": "HOTEL-TEST",
"source_message_id": "%s",
"source_event_index": 1,
"group_code": "GRP-AIQUERY-001",
"target_key_source": "body_current",
"body_thread_used_only_as_evidence": false
}
""".formatted(source.inboxId())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.request_id").value("req-ai-query-case-001"))
.andExpect(jsonPath("$.trace_id").value("trace-ai-query-case-001"))
.andExpect(jsonPath("$.data.matched_order_records[0].object_id").value("ORDER:920000000000000101"))
.andExpect(jsonPath("$.data.matched_order_records[0].object_type").value("group_block"))
.andExpect(jsonPath("$.data.matched_order_records[0].order_id").value("920000000000000101"))
.andExpect(jsonPath("$.data.matched_order_records[0].group_code").value("GRP-AIQUERY-001"))
.andExpect(jsonPath("$.data.pending_or_open_tasks[0].task_id").value("920000000000000301"))
.andExpect(jsonPath("$.data.pending_or_open_tasks[0].source_event_index").value(1))
.andExpect(jsonPath("$.data.pending_or_open_tasks[0].task_status").value("PENDING_CONFIRM"))
.andExpect(jsonPath("$.data.active_workflows.length()").value(0))
.andExpect(jsonPath("$.data.terminated_records.length()").value(0))
.andExpect(jsonPath("$.data.target_object_validation.status").value("conflict"))
.andExpect(jsonPath("$.data.target_object_validation.can_create_new_booking_task").value(false))
.andExpect(jsonPath("$.data.target_object_validation.can_create_update_task").value(false))
.andExpect(jsonPath("$.data.target_object_validation.needs_manual_review_reason").value("OPEN_TASK_EXISTS"))
.andExpect(jsonPath("$.data.key_relationships.group_code_and_confirmation_same_object").value(nullValue()))
.andExpect(content().string(not(containsString("Please handle booking message"))));
}
@Test
void shouldReturnSuccessfulEmptyCaseContextWhenNoObjectMatches() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-empty-001");
mockMvc.perform(post("/api/ai-query/v1/case-context")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Request-Id", "req-ai-query-empty-001")
.content("""
{
"hotel_id": "HOTEL-TEST",
"source_message_id": "%s",
"source_event_index": 1,
"group_code": "GRP-AIQUERY-NOT-FOUND"
}
""".formatted(source.inboxId())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.matched_order_records.length()").value(0))
.andExpect(jsonPath("$.data.pending_or_open_tasks.length()").value(0))
.andExpect(jsonPath("$.data.active_workflows.length()").value(0))
.andExpect(jsonPath("$.data.terminated_records.length()").value(0))
.andExpect(jsonPath("$.data.target_object_validation.status").value("none"))
.andExpect(jsonPath("$.data.target_object_validation.can_create_new_booking_task").value(true));
}
@Test
void shouldNotAllowCreationWhenOnlyUnsupportedReservationNoProvided() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-reservation-no-001");
mockMvc.perform(post("/api/ai-query/v1/case-context")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Request-Id", "req-ai-query-reservation-no-001")
.content("""
{
"hotel_id": "HOTEL-TEST",
"source_message_id": "%s",
"source_event_index": 1,
"reservation_no": "RESV-AIQUERY-001"
}
""".formatted(source.inboxId())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.matched_order_records.length()").value(0))
.andExpect(jsonPath("$.data.pending_or_open_tasks.length()").value(0))
.andExpect(jsonPath("$.data.target_object_validation.status").value("conflict"))
.andExpect(jsonPath("$.data.target_object_validation.can_create_new_booking_task").value(false))
.andExpect(jsonPath("$.data.target_object_validation.needs_manual_review_reason")
.value("UNSUPPORTED_RESERVATION_NO_QUERY"));
}
@Test
void shouldReturnObjectDetailWithNullableOperaProjectionWarning() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-detail-001");
insertActiveGroupOrder(920000000000000401L, source.inboxId(), "GRP-AIQUERY-DETAIL-001");
mockMvc.perform(post("/api/ai-query/v1/object-detail")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Request-Id", "req-ai-query-detail-001")
.header("X-AI-Trace-Id", "trace-ai-query-detail-001")
.content("""
{
"hotel_id": "HOTEL-TEST",
"object_id": "ORDER:920000000000000401",
"object_type": "group_block"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.object_id").value("ORDER:920000000000000401"))
.andExpect(jsonPath("$.data.object_type").value("group_block"))
.andExpect(jsonPath("$.data.order_id").value("920000000000000401"))
.andExpect(jsonPath("$.data.group_code").value("GRP-AIQUERY-DETAIL-001"))
.andExpect(jsonPath("$.data.reservation_no").value(nullValue()))
.andExpect(jsonPath("$.data.block_id").value(nullValue()))
.andExpect(jsonPath("$.data.room_items.length()").value(0))
.andExpect(jsonPath("$.data.rate_code_price").value(nullValue()))
.andExpect(jsonPath("$.data.can_update").value(true))
.andExpect(jsonPath("$.data.can_cancel").value(true))
.andExpect(jsonPath("$.data.hard_validation_warnings[0].code").value("OPERA_PROJECTION_UNAVAILABLE"));
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
return captureService.capture(new CaptureSourceMessageCommand(
HOTEL_ID,
"AGENTBUS",
"EMAIL",
externalMessageId,
"thread-" + externalMessageId,
"frame-" + externalMessageId,
"session-ai-query",
Instant.parse("2026-07-07T08:00:00Z"),
"guest@example.test",
"M002 AI Query",
"Please handle booking message.",
"<html><body>Please handle booking message.</body></html>",
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
"agentbus-outlook-v1",
List.of()
));
}
private void insertActiveGroupOrder(Long orderId, Long sourceMessageId, String groupCode) {
jdbcTemplate.update("""
INSERT INTO workflow_reservation_order (
id, hotel_id, order_key_type, order_business_key, active_business_key,
temporary_order_code, order_status, business_key_source, display_name,
source_message_id, version, created_at, updated_at
)
VALUES (?, ?, 'GROUP_CODE', ?, ?, ?, 'ACTIVE', 'AI_CANDIDATE', ?, ?, 0,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", orderId, HOTEL_ID, groupCode, groupCode, "TMP-" + orderId, groupCode, sourceMessageId);
}
private void insertTransition(Long transitionId, Long sourceMessageId, Integer sourceEventIndex, String groupCode) {
jdbcTemplate.update("""
INSERT INTO workflow_reservation_ai_transition (
id, hotel_id, batch_id, source_message_id, source_event_index, array_index,
execution_order, catalog_code, skill_id, result_type, ai_task_type,
system_task_type, task_card_type, task_subtype, current_or_history,
group_code, item_payload_sha256, item_idempotency_key, blocked_until_parent_completed,
ai_payload_json, case_keys_json, extracted_fields_json, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, 1, 1, 'S02', 'update_booking_amendment_skill',
'normal_task', 'Update Booking', 'UPDATE_BOOKING', 'UPDATE_BOOKING',
'update_stay_dates', 'current', ?, ?, ?, 0, '{}', '{}', '{}',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", transitionId, HOTEL_ID, transitionId - 1, sourceMessageId, sourceEventIndex, groupCode,
"0".repeat(64), "1".repeat(64));
}
private void insertTask(Long taskId, Long orderId, Long sourceMessageId, Long transitionId, String taskStatus) {
jdbcTemplate.update("""
INSERT INTO workflow_reservation_task (
id, hotel_id, order_id, source_message_id, ai_transition_id,
result_type, ai_task_type, system_task_type, task_card_type, task_subtype,
task_status, queue_participation, execution_order, blocked_until_parent_completed,
version, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, 'normal_task', 'Update Booking', 'UPDATE_BOOKING',
'UPDATE_BOOKING', 'update_stay_dates', ?, 1, 1, 0, 0,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", taskId, HOTEL_ID, orderId, sourceMessageId, transitionId, taskStatus);
}
}