实现SuperAgent特殊入口结果处理

This commit is contained in:
andy
2026-07-10 12:05:57 +08:00
parent 9de4f0e7b6
commit 74e429a2cb
29 changed files with 1121 additions and 97 deletions

View File

@@ -4,10 +4,12 @@ import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentTas
import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentTaskResultSecurityService;
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException;
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultProperties;
import cn.nianxx.thhotel.integrations.messaging.agentbus.adapter.AgentBusProperties;
import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultResponse;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService;
import java.nio.charset.StandardCharsets;
import org.springframework.http.HttpStatus;
import org.springframework.http.InvalidMediaTypeException;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
@@ -27,6 +29,7 @@ public class SuperAgentTaskResultController {
private final SuperAgentTaskResultSecurityService securityService;
private final SuperAgentTaskResultProperties properties;
private final AgentBusProperties agentBusProperties;
private final ReservationAiTaskIntakeService intakeService;
/**
@@ -35,18 +38,21 @@ public class SuperAgentTaskResultController {
public SuperAgentTaskResultController(
SuperAgentTaskResultSecurityService securityService,
SuperAgentTaskResultProperties properties,
AgentBusProperties agentBusProperties,
ReservationAiTaskIntakeService intakeService) {
this.securityService = securityService;
this.properties = properties;
this.agentBusProperties = agentBusProperties;
this.intakeService = intakeService;
}
/**
* 接收 SuperAgent AI 任务结果,先限制请求体大小,再完成 HMAC 鉴权,最后委托业务服务创建 CP1-3 数据
* 接收 SuperAgent AI 任务结果,支持 JSON 和 S000/S999 文本结果HMAC 始终基于原始请求体校验
*/
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
@PostMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<SuperAgentTaskResultResponse> accept(
@RequestBody(required = false) String rawBody,
@RequestHeader(name = "Content-Type", required = false) String contentType,
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Client-Id", required = false) String clientId,
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Timestamp", required = false) String timestamp,
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Nonce", required = false) String nonce,
@@ -54,6 +60,7 @@ public class SuperAgentTaskResultController {
@RequestHeader(name = "X-TH-Hotel-Request-Id", required = false) String requestId) {
String requestBody = rawBody == null ? "" : rawBody;
rejectBodyWhenTooLarge(requestBody);
rejectUnsupportedContentType(contentType);
securityService.verify(new SuperAgentTaskResultSecurityRequest(
"POST",
REQUEST_PATH,
@@ -63,11 +70,45 @@ public class SuperAgentTaskResultController {
signature,
requestBody
));
SuperAgentTaskResultResponse response = intakeService.accept(requestBody, clientId, requestId);
SuperAgentTaskResultResponse response = intakeService.accept(
requestBody,
clientId,
requestId,
agentBusProperties.getCapture().getDefaultHotelId());
HttpStatus status = response.idempotentReplay() ? HttpStatus.OK : HttpStatus.CREATED;
return ResponseEntity.status(status).body(response);
}
/**
* 仅允许 JSON 结构化任务或 text/plain 入口结果文本,避免其他媒体类型误入业务解析。
*/
private void rejectUnsupportedContentType(String contentType) {
String rawContentType = contentType == null ? "" : contentType.trim();
if (rawContentType.isEmpty()) {
throw unsupportedContentType();
}
MediaType mediaType;
try {
mediaType = MediaType.parseMediaType(rawContentType);
} catch (InvalidMediaTypeException exception) {
throw unsupportedContentType();
}
if (!MediaType.APPLICATION_JSON.isCompatibleWith(mediaType)
&& !MediaType.TEXT_PLAIN.isCompatibleWith(mediaType)) {
throw unsupportedContentType();
}
}
/**
* 构造统一的 Content-Type 不支持错误,避免把外部原始 Header 写入响应。
*/
private SuperAgentTaskResultException unsupportedContentType() {
return new SuperAgentTaskResultException(
HttpStatus.UNSUPPORTED_MEDIA_TYPE,
"REQUEST_CONTENT_TYPE_UNSUPPORTED",
"Content-Type 仅支持 application/json 或 text/plain。");
}
/**
* 在解析 JSON 和校验 Header 之前限制请求体大小,避免超限请求进入后续处理。
*/

View File

@@ -0,0 +1,45 @@
package cn.nianxx.thhotel.platform.common.enums;
import java.util.Arrays;
import java.util.Optional;
/**
* SuperAgent 入口阶段非结构化结果码。该结果只说明来源消息处理结论,不代表业务任务。
*/
public enum SourceMessageOnlyResultCode {
/** 纯信息类邮件,不需要形成业务素材包。 */
S000("S000", "PURE_INFORMATION", "纯信息类邮件"),
/** 入口问题导致无法形成业务素材包。 */
S999("S999", "MATERIAL_PACKAGE_UNAVAILABLE", "无法形成业务素材包");
private final String code;
private final String meaning;
private final String description;
SourceMessageOnlyResultCode(String code, String meaning, String description) {
this.code = code;
this.meaning = meaning;
this.description = description;
}
public String code() {
return code;
}
public String meaning() {
return meaning;
}
public String description() {
return description;
}
/**
* 按 SuperAgent 返回的稳定代码查找枚举,大小写敏感,避免误吞其他文本。
*/
public static Optional<SourceMessageOnlyResultCode> fromCode(String code) {
return Arrays.stream(values())
.filter(value -> value.code.equals(code))
.findFirst();
}
}

View File

@@ -9,6 +9,7 @@ import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStor
import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.impl.AliyunOssProperties;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.impl.ObjectStorageException;
import cn.nianxx.thhotel.platform.common.enums.SourceMessageOnlyResultCode;
import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunDraft;
import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunStatusUpdate;
import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunUpdate;
@@ -27,8 +28,11 @@ import cn.nianxx.thhotel.platform.message.service.EmlMessageParseService;
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
import cn.nianxx.thhotel.platform.message.service.SourceMessageHtmlSanitizerService;
import cn.nianxx.thhotel.platform.message.service.impl.EmlMessageParseException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.net.SocketTimeoutException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@@ -47,6 +51,8 @@ import java.util.regex.Pattern;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
/**
* Debug EML 上传到 SuperAgent 服务实现。编排解析、OSS、SourceMessage 和 SuperAgent 调用。
@@ -139,7 +145,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
"OSS 上传失败。",
exception);
} catch (SuperAgentOpenApiException exception) {
markFailed(runId, "SuperAgent 调用失败。", DebugEmlSuperAgentRunStatus.SUPERAGENT_FAILED, nowUtc());
markFailed(runId, superAgentFailureSummary(exception), DebugEmlSuperAgentRunStatus.SUPERAGENT_FAILED, nowUtc());
throw new DebugEmlSuperAgentException(
HttpStatus.BAD_GATEWAY,
"SUPERAGENT_OPEN_API_FAILED",
@@ -155,6 +161,66 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
}
}
/**
* 生成 SuperAgent 失败安全摘要,只记录错误类型,不保存响应 body、API Key、正文或附件 URL。
*/
private String superAgentFailureSummary(SuperAgentOpenApiException exception) {
Throwable rootCause = rootCause(exception);
if (rootCause instanceof RestClientResponseException responseException) {
return "SuperAgent Open API HTTP 调用失败HTTP 状态:" + responseException.getStatusCode().value() + "";
}
if (hasCause(exception, SocketTimeoutException.class)) {
return "SuperAgent Open API 调用超时。";
}
if (hasCause(exception, ResourceAccessException.class)) {
return "SuperAgent Open API 网络连接失败。";
}
if (rootCause instanceof JsonProcessingException) {
return "SuperAgent Open API 响应 JSON 解析失败。";
}
String message = trimToNull(exception.getMessage());
if (message != null && !"SuperAgent Open API 调用失败。".equals(message)) {
return safeErrorSummary(message);
}
return "SuperAgent Open API 调用失败。";
}
/**
* 取最底层异常,便于判断 HTTP、网络和解析失败类型。
*/
private Throwable rootCause(Throwable throwable) {
Throwable current = throwable;
while (current.getCause() != null) {
current = current.getCause();
}
return current;
}
/**
* 判断异常链中是否包含指定类型。
*/
private boolean hasCause(Throwable throwable, Class<? extends Throwable> causeType) {
Throwable current = throwable;
while (current != null) {
if (causeType.isInstance(current)) {
return true;
}
current = current.getCause();
}
return false;
}
/**
* 限制错误摘要长度,避免外部异常消息过长进入调试表。
*/
private String safeErrorSummary(String message) {
String value = message.trim();
if (value.length() <= 512) {
return value;
}
return value.substring(0, 512);
}
/**
* 执行已创建 runId 的主流程。
*/
@@ -534,6 +600,10 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
warnings.add("SuperAgent 最终回答为空。");
return null;
}
JsonNode sourceMessageOnlyResult = parseSourceMessageOnlyResult(rawAnswer);
if (sourceMessageOnlyResult != null) {
return sourceMessageOnlyResult;
}
try {
return objectMapper.readTree(rawAnswer);
} catch (Exception exception) {
@@ -542,6 +612,32 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
}
}
/**
* 识别 Debug 链路中的 S000/S999 入口结果,避免把可识别文本误报为 JSON 解析失败。
*/
private JsonNode parseSourceMessageOnlyResult(String rawAnswer) {
String trimmedAnswer = trimToNull(rawAnswer);
if (trimmedAnswer == null) {
return null;
}
int separatorIndex = trimmedAnswer.indexOf(',');
String code = separatorIndex < 0 ? trimmedAnswer : trimmedAnswer.substring(0, separatorIndex).trim();
SourceMessageOnlyResultCode resultCode = SourceMessageOnlyResultCode.fromCode(code).orElse(null);
if (resultCode == null || separatorIndex < 0) {
return null;
}
String sourceMessageId = trimToNull(trimmedAnswer.substring(separatorIndex + 1));
if (sourceMessageId == null) {
return null;
}
ObjectNode result = objectMapper.createObjectNode();
result.put("entry_result_code", resultCode.code());
result.put("entry_result_source_message_id", sourceMessageId);
result.put("entry_result_meaning", resultCode.meaning());
result.put("entry_result_description", resultCode.description());
return result;
}
/**
* 标记 Debug 运行失败。
*/

View File

@@ -12,6 +12,7 @@ import java.time.LocalDateTime;
* @param activeBusinessKey 当前生效业务号
* @param temporaryOrderCode 临时订单号
* @param orderStatus 订单状态
* @param orderVisibility 订单前端可见性
* @param businessKeySource 业务号来源
* @param displayName 前端和 Skill 可读展示名
* @param sourceMessageId 订单来源 SourceMessage ID
@@ -30,6 +31,7 @@ public record ReservationAiQueryOrderSnapshot(
String activeBusinessKey,
String temporaryOrderCode,
String orderStatus,
String orderVisibility,
String businessKeySource,
String displayName,
Long sourceMessageId,

View File

@@ -3,7 +3,20 @@ package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.time.LocalDateTime;
/**
* 订单入库草稿。用于创建临时订单带业务号的有效订单。
* 订单入库草稿。用于创建临时订单带业务号的有效订单或隐藏技术订单
*
* @param hotelId 酒店上下文 ID
* @param orderKeyType 订单业务号类型
* @param orderBusinessKey 订单业务号
* @param activeBusinessKey ACTIVE 状态唯一约束辅助业务号
* @param temporaryOrderCode 临时订单号
* @param orderStatus 订单状态
* @param orderVisibility 订单前端可见性
* @param businessKeySource 业务号来源
* @param businessKeyBackfilledAt 业务号回填 UTC 时间
* @param displayName 前端展示名
* @param sourceMessageId 来源消息 ID
* @param now 创建和更新时间
*/
public record ReservationOrderDraft(
String hotelId,
@@ -12,6 +25,7 @@ public record ReservationOrderDraft(
String activeBusinessKey,
String temporaryOrderCode,
String orderStatus,
String orderVisibility,
String businessKeySource,
LocalDateTime businessKeyBackfilledAt,
String displayName,

View File

@@ -2,6 +2,15 @@ package cn.nianxx.thhotel.workflows.reservation.common.dto;
/**
* 订单快照。Service 通过快照完成挂靠,不直接使用数据库 Entity。
*
* @param id 订单主键
* @param hotelId 酒店上下文 ID
* @param orderKeyType 订单业务号类型
* @param orderBusinessKey 订单业务号
* @param activeBusinessKey ACTIVE 状态唯一约束辅助业务号
* @param temporaryOrderCode 临时订单号
* @param orderStatus 订单状态
* @param orderVisibility 订单前端可见性
*/
public record ReservationOrderSnapshot(
Long id,
@@ -10,6 +19,7 @@ public record ReservationOrderSnapshot(
String orderBusinessKey,
String activeBusinessKey,
String temporaryOrderCode,
String orderStatus
String orderStatus,
String orderVisibility
) {
}

View File

@@ -1,7 +1,7 @@
package cn.nianxx.thhotel.workflows.reservation.common.enums;
/**
* AI 结果类型稳定代码。第一版只允许 normal_taskmanual_reviewinformational_message。
* AI 结构化结果类型稳定代码。新入口只使用 normal_taskmanual_reviewinformational_message 仅历史兼容
*/
public enum AiResultType {
NORMAL_TASK("normal_task"),

View File

@@ -0,0 +1,11 @@
package cn.nianxx.thhotel.workflows.reservation.common.enums;
/**
* 订单前端可见性。用于区分真实业务订单和仅为系统任务挂靠使用的技术订单。
*/
public enum ReservationOrderVisibility {
/** 普通业务订单,默认在订单列表和订单详情中可见。 */
VISIBLE,
/** 系统隐藏订单,仅用于 S000/S999 等来源消息结果任务挂靠。 */
HIDDEN_SYSTEM
}

View File

@@ -8,5 +8,6 @@ public enum ReservationSystemTaskType {
UPDATE_BOOKING,
CANCEL_BOOKING,
MANUAL_REVIEW,
INFORMATIONAL_MESSAGE
INFORMATIONAL_MESSAGE,
SOURCE_MESSAGE_ONLY
}

View File

@@ -13,5 +13,6 @@ public enum ReservationTaskCardType {
TRACE_RESERVATION_NOTES,
TA_RECORDER,
MESSAGE_NOTIFICATION,
FALLBACK_REVIEW
FALLBACK_REVIEW,
SOURCE_MESSAGE_ONLY
}

View File

@@ -0,0 +1,26 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* S000/S999 特殊入口结果详情。仅用于 SOURCE_MESSAGE_ONLY 任务,不承载普通任务 AI 原始 payload。
*
* @param entryResultCode SuperAgent 入口结果码
* @param entryResultMeaning 入口结果语义
* @param entryResultDescription 入口结果中文说明
* @param entryResultSourceMessageId SuperAgent 回传的外部来源消息 ID
* @param rawAnswer SuperAgent 原始文本返回
*/
public record ReservationSourceMessageOnlyResult(
@JsonProperty("entry_result_code")
String entryResultCode,
@JsonProperty("entry_result_meaning")
String entryResultMeaning,
@JsonProperty("entry_result_description")
String entryResultDescription,
@JsonProperty("entry_result_source_message_id")
String entryResultSourceMessageId,
@JsonProperty("raw_answer")
String rawAnswer
) {
}

View File

@@ -21,6 +21,7 @@ import java.util.List;
* @param fieldContractVersion 字段矩阵契约版本
* @param draftPayload 草稿 payload
* @param confirmedPayload 最终确认 payload
* @param sourceMessageOnlyResult S000/S999 特殊入口结果,普通任务为空
* @param availability 当前可处理状态
* @param fields 按字段矩阵生成的字段列表
* @param operaOperations OPERA 模拟操作列表
@@ -54,6 +55,8 @@ public record ReservationTaskDetailResult(
Object draftPayload,
@JsonProperty("confirmed_payload")
Object confirmedPayload,
@JsonProperty("source_message_only_result")
ReservationSourceMessageOnlyResult sourceMessageOnlyResult,
ReservationTaskAvailabilityResult availability,
List<ReservationTaskFieldResult> fields,
@JsonProperty("opera_operations")

View File

@@ -26,6 +26,8 @@ public class ReservationOrderEntity {
private String temporaryOrderCode;
/** 订单状态。 */
private String orderStatus;
/** 订单前端可见性,系统隐藏订单不进入订单列表。 */
private String orderVisibility;
/** 业务号来源,例如 AI 候选、用户确认或 OPERA 模拟回填。 */
private String businessKeySource;
/** New Booking 成功后回填真实业务号的 UTC 时间。 */
@@ -63,6 +65,8 @@ public class ReservationOrderEntity {
public void setTemporaryOrderCode(String temporaryOrderCode) { this.temporaryOrderCode = temporaryOrderCode; }
public String getOrderStatus() { return orderStatus; }
public void setOrderStatus(String orderStatus) { this.orderStatus = orderStatus; }
public String getOrderVisibility() { return orderVisibility; }
public void setOrderVisibility(String orderVisibility) { this.orderVisibility = orderVisibility; }
public String getBusinessKeySource() { return businessKeySource; }
public void setBusinessKeySource(String businessKeySource) { this.businessKeySource = businessKeySource; }
public LocalDateTime getBusinessKeyBackfilledAt() { return businessKeyBackfilledAt; }

View File

@@ -22,6 +22,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderLi
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest;
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.ReservationOrderVisibility;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationAiBatchEntity;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationAiTransitionEntity;
@@ -192,6 +193,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
entity.setActiveBusinessKey(draft.activeBusinessKey());
entity.setTemporaryOrderCode(draft.temporaryOrderCode());
entity.setOrderStatus(draft.orderStatus());
entity.setOrderVisibility(draft.orderVisibility());
entity.setBusinessKeySource(draft.businessKeySource());
entity.setBusinessKeyBackfilledAt(draft.businessKeyBackfilledAt());
entity.setDisplayName(draft.displayName());
@@ -385,6 +387,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
return orderMapper.selectList(Wrappers.<ReservationOrderEntity>lambdaQuery()
.eq(ReservationOrderEntity::getHotelId, hotelId)
.in(ReservationOrderEntity::getSourceMessageId, sourceMessageIds)
.ne(ReservationOrderEntity::getOrderVisibility, ReservationOrderVisibility.HIDDEN_SYSTEM.name())
.orderByDesc(ReservationOrderEntity::getUpdatedAt))
.stream()
.map(this::toAiQueryOrderSnapshot)
@@ -404,6 +407,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
Page<ReservationOrderEntity> page = orderMapper.selectPage(Page.of(pageNum, pageSize),
Wrappers.<ReservationOrderEntity>lambdaQuery()
.eq(ReservationOrderEntity::getHotelId, request.hotelId())
.ne(ReservationOrderEntity::getOrderVisibility, ReservationOrderVisibility.HIDDEN_SYSTEM.name())
.eq(hasText(request.orderStatus()),
ReservationOrderEntity::getOrderStatus,
trim(request.orderStatus()))
@@ -930,7 +934,8 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
entity.getOrderBusinessKey(),
entity.getActiveBusinessKey(),
entity.getTemporaryOrderCode(),
entity.getOrderStatus());
entity.getOrderStatus(),
entity.getOrderVisibility());
}
/**
@@ -945,6 +950,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
entity.getActiveBusinessKey(),
entity.getTemporaryOrderCode(),
entity.getOrderStatus(),
entity.getOrderVisibility(),
entity.getBusinessKeySource(),
entity.getDisplayName(),
entity.getSourceMessageId(),

View File

@@ -3,7 +3,7 @@ package cn.nianxx.thhotel.workflows.reservation.service;
import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultResponse;
/**
* Reservation AI 任务结果接收服务。负责把 SuperAgent JSON 转换为 AI 过渡层、订单、任务和任务卡
* Reservation AI 任务结果接收服务。负责把 SuperAgent JSON 或入口结果文本转换为内部可追溯任务
*/
public interface ReservationAiTaskIntakeService {
@@ -11,4 +11,9 @@ public interface ReservationAiTaskIntakeService {
* 接收已通过鉴权的 SuperAgent 原始请求体,并完成 CP1-3 范围内的持久化和任务创建。
*/
SuperAgentTaskResultResponse accept(String rawBody, String clientId, String requestId);
/**
* 接收已通过鉴权的 SuperAgent 原始请求体defaultHotelId 仅用于 S000/S999 文本结果反查 SourceMessage。
*/
SuperAgentTaskResultResponse accept(String rawBody, String clientId, String requestId, String defaultHotelId);
}

View File

@@ -30,7 +30,8 @@ public class JsonReservationTaskCardFieldDefinitionProvider implements Reservati
Map.entry(ReservationTaskCardType.TRACE_RESERVATION_NOTES.name(), "Trace / Reservation Notes 卡"),
Map.entry(ReservationTaskCardType.TA_RECORDER.name(), "TA Recorder 卡"),
Map.entry(ReservationTaskCardType.MESSAGE_NOTIFICATION.name(), "Message Notification 信息提醒卡"),
Map.entry(ReservationTaskCardType.FALLBACK_REVIEW.name(), "Fallback 人工复核卡")
Map.entry(ReservationTaskCardType.FALLBACK_REVIEW.name(), "Fallback 人工复核卡"),
Map.entry(ReservationTaskCardType.SOURCE_MESSAGE_ONLY.name(), "Source Message Only 只读卡")
);
private final ObjectMapper objectMapper;

View File

@@ -2,6 +2,7 @@ package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot;
import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository;
import cn.nianxx.thhotel.platform.common.enums.SourceMessageOnlyResultCode;
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.ReservationAiTransitionDraft;
@@ -13,6 +14,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.enums.AiResultType;
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;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderVisibility;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationSystemTaskType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskCardType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
@@ -23,6 +25,7 @@ import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowR
import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -45,6 +48,9 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
private static final String FIELD_CONTRACT_VERSION = "code-v1";
private static final String BATCH_KEY_PREFIX = "superagent-task-result-batch:v1";
private static final String ITEM_KEY_PREFIX = "superagent-task-result-item:v1";
private static final String SOURCE_MESSAGE_ONLY_RESULT_TYPE = "source_message_only";
private static final String SOURCE_MESSAGE_ONLY_CATALOG_CODE = "ENTRY_RESULT";
private static final String SOURCE_MESSAGE_ONLY_SKILL_ID = "superagent_entry_router";
private static final String DEFAULT_SOURCE_PROVIDER = "AGENTBUS";
private static final String DEFAULT_SOURCE_CHANNEL = "EMAIL";
private static final int LENGTH_32 = 32;
@@ -75,6 +81,25 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
@Override
@Transactional
public SuperAgentTaskResultResponse accept(String rawBody, String clientId, String requestId) {
return accept(rawBody, clientId, requestId, null);
}
/**
* 接收已通过 HMAC 鉴权的 SuperAgent 任务结果。S000/S999 文本结果使用默认酒店反查来源邮件。
*/
@Override
@Transactional
public SuperAgentTaskResultResponse accept(String rawBody, String clientId, String requestId, String defaultHotelId) {
String requestBody = rawBody == null ? "" : rawBody;
SourceMessageOnlyEntryResult sourceMessageOnlyEntryResult = parseSourceMessageOnlyEntryResult(requestBody);
if (sourceMessageOnlyEntryResult != null) {
return acceptSourceMessageOnlyEntryResult(
sourceMessageOnlyEntryResult,
clientId,
requestId,
defaultHotelId,
requestBody);
}
JsonNode root = parseJson(rawBody);
ResolvedSourceMessage resolvedSourceMessage = resolveSourceMessage(root);
SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage.snapshot();
@@ -136,6 +161,183 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
);
}
/**
* 接收 S000/S999 文本入口结果,创建只读来源消息任务,并隐藏其技术订单。
*/
private SuperAgentTaskResultResponse acceptSourceMessageOnlyEntryResult(
SourceMessageOnlyEntryResult entryResult,
String clientId,
String requestId,
String defaultHotelId,
String rawBody) {
String hotelId = requireText(defaultHotelId, "default_hotel_id", LENGTH_64);
SourceMessageInboxSnapshot sourceMessage = sourceMessageInboxRepository
.findByIdempotencyKey(
hotelId,
DEFAULT_SOURCE_PROVIDER,
DEFAULT_SOURCE_CHANNEL,
entryResult.externalSourceMessageId())
.orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。"));
String requestPayloadSha256 = sha256(rawBody == null ? "" : rawBody);
String batchIdempotencyKey = sha256(BATCH_KEY_PREFIX + "|" + sourceMessage.id() + "|" + requestPayloadSha256);
ReservationAiBatchSnapshot existingBatch = workflowRepository
.findBatchBySourceMessageId(hotelId, sourceMessage.id())
.orElse(null);
if (existingBatch != null) {
return handleExistingBatch(
requestId,
entryResult.externalSourceMessageId(),
requestPayloadSha256,
existingBatch);
}
LocalDateTime now = nowUtc();
String safeRequestId = optionalText(requestId, "request_id", LENGTH_128);
ReservationAiBatchDraft batchDraft = new ReservationAiBatchDraft(
hotelId,
sourceMessage.id(),
requestPayloadSha256,
batchIdempotencyKey,
requireText(clientId, "clientId", LENGTH_128),
safeRequestId,
now,
1,
null
);
Long batchId = insertBatchOrReplay(batchDraft);
if (batchId == null) {
return handleExistingBatch(
requestId,
entryResult.externalSourceMessageId(),
requestPayloadSha256,
workflowRepository.findBatchBySourceMessageId(hotelId, sourceMessage.id())
.orElseThrow(() -> error(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT", "AI 批次并发写入状态不确定。")));
}
SuperAgentTaskResultItemResponse item = createSourceMessageOnlyItem(
hotelId,
sourceMessage.id(),
batchId,
entryResult,
rawBody,
now);
return new SuperAgentTaskResultResponse(
safeRequestId,
entryResult.externalSourceMessageId(),
batchId.toString(),
false,
1,
List.of(item),
List.of());
}
/**
* 创建 S000/S999 对应的只读任务。该任务只用于任务列表和详情展示,不参与订单执行队列。
*/
private SuperAgentTaskResultItemResponse createSourceMessageOnlyItem(
String hotelId,
Long sourceMessageId,
Long batchId,
SourceMessageOnlyEntryResult entryResult,
String rawBody,
LocalDateTime now) {
ObjectNode itemPayload = objectMapper.createObjectNode();
itemPayload.put("entry_result_code", entryResult.resultCode().code());
itemPayload.put("entry_result_meaning", entryResult.resultCode().meaning());
itemPayload.put("entry_result_description", entryResult.resultCode().description());
itemPayload.put("source_message_id", entryResult.externalSourceMessageId());
itemPayload.put("raw_answer", rawBody);
String itemPayloadJson = nodeJson(itemPayload);
String itemPayloadSha256 = sha256(itemPayloadJson);
String itemIdempotencyKey = sha256(ITEM_KEY_PREFIX
+ "|" + sourceMessageId
+ "|1|1|"
+ SOURCE_MESSAGE_ONLY_CATALOG_CODE
+ "|"
+ SOURCE_MESSAGE_ONLY_SKILL_ID
+ "|"
+ SOURCE_MESSAGE_ONLY_RESULT_TYPE
+ "|"
+ entryResult.resultCode().code()
+ "|"
+ entryResult.resultCode().code()
+ "|"
+ itemPayloadSha256);
Long transitionId = workflowRepository.insertTransition(new ReservationAiTransitionDraft(
hotelId,
batchId,
sourceMessageId,
1,
1,
1,
SOURCE_MESSAGE_ONLY_CATALOG_CODE,
SOURCE_MESSAGE_ONLY_SKILL_ID,
SOURCE_MESSAGE_ONLY_RESULT_TYPE,
entryResult.resultCode().code(),
ReservationSystemTaskType.SOURCE_MESSAGE_ONLY.name(),
ReservationTaskCardType.SOURCE_MESSAGE_ONLY.name(),
entryResult.resultCode().code(),
null,
null,
null,
itemPayloadSha256,
itemIdempotencyKey,
null,
null,
null,
Boolean.FALSE,
itemPayloadJson,
null,
null,
null,
null,
null,
null,
now
));
ReservationOrderSnapshot order = createHiddenSourceMessageOnlyOrder(hotelId, sourceMessageId, now);
TaskTypeMapping mapping = new TaskTypeMapping(
ReservationSystemTaskType.SOURCE_MESSAGE_ONLY,
ReservationTaskCardType.SOURCE_MESSAGE_ONLY);
TaskCreation taskCreation = insertTaskWithQueueRetry(
hotelId,
sourceMessageId,
transitionId,
SOURCE_MESSAGE_ONLY_RESULT_TYPE,
entryResult.resultCode().code(),
mapping,
entryResult.resultCode().code(),
ReservationTaskStatus.COMPLETED.name(),
false,
null,
null,
Boolean.FALSE,
now,
order,
now
);
workflowRepository.insertTaskCard(new ReservationTaskCardDraft(
hotelId,
taskCreation.taskId(),
ReservationTaskCardType.SOURCE_MESSAGE_ONLY.name(),
FIELD_CONTRACT_VERSION,
itemPayloadJson,
now
));
return new SuperAgentTaskResultItemResponse(
1,
1,
transitionId.toString(),
order.id().toString(),
taskCreation.taskId().toString(),
ReservationSystemTaskType.SOURCE_MESSAGE_ONLY.name(),
ReservationTaskCardType.SOURCE_MESSAGE_ONLY.name(),
ReservationTaskStatus.COMPLETED.name(),
order.orderStatus(),
taskCreation.executionOrder()
);
}
/**
* 插入批次;如果并发请求已经写入同一 SourceMessage则交由调用方按幂等规则重新读取处理。
*/
@@ -395,6 +597,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
businessKey.value(),
temporaryOrderCode(sourceMessageId, arrayIndex),
ReservationOrderStatus.ACTIVE.name(),
ReservationOrderVisibility.VISIBLE.name(),
ReservationBusinessKeySource.AI_CANDIDATE.name(),
null,
businessKey.value(),
@@ -424,6 +627,31 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
null,
temporaryCode,
ReservationOrderStatus.TEMPORARY.name(),
ReservationOrderVisibility.VISIBLE.name(),
null,
null,
temporaryCode,
sourceMessageId,
now
));
}
/**
* 创建 S000/S999 专用隐藏技术订单。它只满足任务外键归属,不进入前端订单列表。
*/
private ReservationOrderSnapshot createHiddenSourceMessageOnlyOrder(
String hotelId,
Long sourceMessageId,
LocalDateTime now) {
String temporaryCode = temporaryOrderCode(sourceMessageId, 1);
return workflowRepository.insertOrder(new ReservationOrderDraft(
hotelId,
ReservationOrderKeyType.TEMPORARY.name(),
null,
null,
temporaryCode,
ReservationOrderStatus.TEMPORARY.name(),
ReservationOrderVisibility.HIDDEN_SYSTEM.name(),
null,
null,
temporaryCode,
@@ -476,6 +704,31 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
};
}
/**
* 解析 SuperAgent 入口阶段的纯文本结果。只支持 S000/S999不把其他文本误判为业务结果。
*/
private SourceMessageOnlyEntryResult parseSourceMessageOnlyEntryResult(String rawBody) {
String trimmedBody = trimToNull(rawBody);
if (trimmedBody == null) {
return null;
}
int separatorIndex = trimmedBody.indexOf(',');
String code = separatorIndex < 0 ? trimmedBody : trimmedBody.substring(0, separatorIndex).trim();
SourceMessageOnlyResultCode resultCode = SourceMessageOnlyResultCode.fromCode(code).orElse(null);
if (resultCode == null) {
return null;
}
if (separatorIndex < 0) {
throw error(HttpStatus.BAD_REQUEST, "SOURCE_MESSAGE_REQUIRED", "S000/S999 结果缺少 source_message_id。");
}
String externalSourceMessageId = trimToNull(trimmedBody.substring(separatorIndex + 1));
if (externalSourceMessageId == null) {
throw error(HttpStatus.BAD_REQUEST, "SOURCE_MESSAGE_REQUIRED", "S000/S999 结果缺少 source_message_id。");
}
validateLength(externalSourceMessageId, "source_message_id", LENGTH_256);
return new SourceMessageOnlyEntryResult(resultCode, externalSourceMessageId);
}
/**
* 解析请求体 JSON解析失败返回受控错误不输出原始请求体。
*/
@@ -703,6 +956,15 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
) {
}
/**
* SuperAgent S000/S999 文本结果解析值。
*/
private record SourceMessageOnlyEntryResult(
SourceMessageOnlyResultCode resultCode,
String externalSourceMessageId
) {
}
/**
* 任务创建结果,包含任务 ID 和最终写入的同订单执行序号。
*/

View File

@@ -8,6 +8,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryTask
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot;
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.ReservationOrderVisibility;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderListQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest;
@@ -146,6 +147,12 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
HttpStatus.NOT_FOUND,
"ORDER_NOT_FOUND",
"订单不存在。"));
if (isHiddenSystemOrder(order)) {
throw new ReservationTaskWorkflowException(
HttpStatus.NOT_FOUND,
"ORDER_NOT_FOUND",
"订单不存在。");
}
List<ReservationAiQueryTaskSnapshot> taskSnapshots = Boolean.FALSE.equals(includeTasks)
? List.of()
: workflowRepository.findAiQueryTasksByOrderIds(normalizedHotelId, List.of(order.id()));
@@ -316,7 +323,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
task.orderId().toString(),
task.hotelId(),
displayOrderKey(order),
order == null ? null : order.temporaryOrderCode(),
order == null || isHiddenSystemOrder(order) ? null : order.temporaryOrderCode(),
task.systemTaskType(),
task.taskSubtype(),
task.taskStatus(),
@@ -504,7 +511,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
* 生成订单展示键,优先真实业务号,其次临时订单号。
*/
private String displayOrderKey(ReservationAiQueryOrderSnapshot order) {
if (order == null) {
if (order == null || isHiddenSystemOrder(order)) {
return null;
}
String activeBusinessKey = trimToNull(order.activeBusinessKey());
@@ -519,6 +526,9 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
* 从订单快照中提取 Group Code。
*/
private String groupCode(ReservationAiQueryOrderSnapshot order) {
if (isHiddenSystemOrder(order)) {
return null;
}
if (ReservationOrderKeyType.GROUP_CODE.name().equals(order.orderKeyType())) {
return displayOrderKey(order);
}
@@ -529,12 +539,22 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
* 从订单快照中提取 Confirmation No.。
*/
private String confirmationNumber(ReservationAiQueryOrderSnapshot order) {
if (isHiddenSystemOrder(order)) {
return null;
}
if (ReservationOrderKeyType.CONFIRMATION_NUMBER.name().equals(order.orderKeyType())) {
return displayOrderKey(order);
}
return null;
}
/**
* 判断订单是否为系统隐藏技术订单。该类订单只支撑特殊任务挂靠,不进入普通订单体验。
*/
private boolean isHiddenSystemOrder(ReservationAiQueryOrderSnapshot order) {
return order != null && ReservationOrderVisibility.HIDDEN_SYSTEM.name().equals(order.orderVisibility());
}
/**
* 标准化酒店 ID。第一版未接用户酒店上下文时使用本地默认酒店。
*/

View File

@@ -26,6 +26,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskPay
import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewConversionResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationAttemptResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationSourceMessageOnlyResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAuditListResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAuditLogResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAvailabilityResult;
@@ -109,6 +110,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
ReservationTaskAvailabilityResult availability = availabilityResolver.calculateAvailability(task);
List<ReservationTaskFieldResult> fields = buildFieldResults(task, taskCard);
List<ReservationOperaOperationResult> operaOperations = findOperaOperationResults(task);
ReservationSourceMessageOnlyResult sourceMessageOnlyResult = buildSourceMessageOnlyResult(task, taskCard);
SourceMessageDetailContext sourceContext = findSourceMessageDetailContext(task);
return new ReservationTaskDetailResult(
task.id().toString(),
@@ -125,6 +127,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
taskCard.fieldContractVersion(),
jsonPayloadToObject(taskCard.draftPayloadJson()),
jsonPayloadToObject(taskCard.confirmedPayloadJson()),
sourceMessageOnlyResult,
availability,
fields,
operaOperations);
@@ -836,6 +839,24 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
}
}
/**
* 只为 S000/S999 特殊任务透出入口结果,避免普通任务详情暴露完整 AI 原始 payload。
*/
private ReservationSourceMessageOnlyResult buildSourceMessageOnlyResult(
ReservationTaskSnapshot task,
ReservationTaskCardSnapshot taskCard) {
if (!ReservationSystemTaskType.SOURCE_MESSAGE_ONLY.name().equals(task.systemTaskType())) {
return null;
}
JsonNode payload = parseJson(taskCard.aiPayloadJson());
return new ReservationSourceMessageOnlyResult(
textValue(payload, "entry_result_code"),
textValue(payload, "entry_result_meaning"),
textValue(payload, "entry_result_description"),
textValue(payload, "source_message_id"),
textValue(payload, "raw_answer"));
}
/**
* 写入草稿保存或最终确认审计。审计只记录 payload 摘要,不保存完整客户字段。
*/
@@ -1339,6 +1360,20 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
}
}
/**
* 从 JSON 节点安全读取文本字段,缺失或 null 时返回 null。
*/
private String textValue(JsonNode node, String fieldName) {
if (node == null || fieldName == null) {
return null;
}
JsonNode valueNode = node.path(fieldName);
if (valueNode.isMissingNode() || valueNode.isNull()) {
return null;
}
return valueNode.asText();
}
/**
* 解析人工转换目标类型,只允许 New、Update 和 Cancel。
*/

View File

@@ -0,0 +1,7 @@
-- M002 S000/S999订单可见性。技术订单只用于挂靠来源消息结果任务不进入前端订单列表。
ALTER TABLE workflow_reservation_order
ADD COLUMN order_visibility VARCHAR(32) NOT NULL DEFAULT 'VISIBLE' COMMENT '订单前端可见性VISIBLE 普通业务订单HIDDEN_SYSTEM 系统隐藏技术订单';
-- M002 S000/S999订单列表和按状态查询默认只读取可见业务订单。
CREATE INDEX idx_reservation_order_visibility_status
ON workflow_reservation_order (hotel_id, order_visibility, order_status, updated_at);

View File

@@ -31,6 +31,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.client.RestClientResponseException;
@SpringBootTest(
classes = ThHotelApplication.class,
@@ -212,6 +213,43 @@ class DebugEmlSuperAgentControllerTest {
.andExpect(jsonPath("$.html_render_mode").value("SANITIZED_HTML"));
}
@Test
void shouldTreatSuperAgentS000AnswerAsRecognizedEntryResult() throws Exception {
when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
ObjectStoragePutRequest request = invocation.getArgument(0);
return new ObjectStoragePutResult(
request.objectKey(),
"https://oss.example.test/" + request.objectKey(),
request.contentType(),
request.sizeBytes());
});
when(superAgentOpenApiClient.invokeMailDebug(any())).thenReturn(new SuperAgentOpenApiResult(
"session-debug-s000",
"run-debug-s000",
"profile-debug",
"profile-version-debug",
"debug-model",
"S000,debug-eml-run-source",
11,
3,
14,
List.of("metadata", "values", "end")));
mockMvc.perform(multipart(ENDPOINT)
.file(emlFile())
.param("hotel_id", "HOTEL-TEST")
.param("run_label", "s000-debug-upload")
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.superagent_raw_answer").value("S000,debug-eml-run-source"))
.andExpect(jsonPath("$.superagent_parsed_json.entry_result_code").value("S000"))
.andExpect(jsonPath("$.superagent_parsed_json.entry_result_source_message_id")
.value("debug-eml-run-source"))
.andExpect(jsonPath("$.superagent_parsed_json.entry_result_meaning").value("PURE_INFORMATION"))
.andExpect(jsonPath("$.status").value("SUPERAGENT_SUCCEEDED"))
.andExpect(content().string(not(containsString("不是合法 JSON"))));
}
@Test
void shouldKeepCapturedSourceMessageWhenSuperAgentFails() throws Exception {
when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
@@ -223,7 +261,15 @@ class DebugEmlSuperAgentControllerTest {
request.sizeBytes());
});
when(superAgentOpenApiClient.invokeMailDebug(any()))
.thenThrow(new SuperAgentOpenApiException("SuperAgent Open API 调用失败。"));
.thenThrow(new SuperAgentOpenApiException(
"SuperAgent Open API 调用失败。",
new RestClientResponseException(
"401 Unauthorized",
401,
"Unauthorized",
null,
new byte[0],
StandardCharsets.UTF_8)));
mockMvc.perform(multipart(ENDPOINT)
.file(emlFile())
@@ -232,6 +278,7 @@ class DebugEmlSuperAgentControllerTest {
.andExpect(status().isBadGateway())
.andExpect(jsonPath("$.error_code").value("SUPERAGENT_OPEN_API_FAILED"))
.andExpect(content().string(not(containsString("test-debug-upload-key"))))
.andExpect(content().string(not(containsString("401"))))
.andExpect(content().string(not(containsString("Please create booking"))));
Long linkedFailedRunCount = jdbcTemplate.queryForObject("""
@@ -241,7 +288,7 @@ class DebugEmlSuperAgentControllerTest {
AND run_status = 'SUPERAGENT_FAILED'
AND source_message_id IS NOT NULL
AND original_eml_oss_url IS NOT NULL
AND safe_error_summary = 'SuperAgent 调用失败。'
AND safe_error_summary = 'SuperAgent Open API HTTP 调用失败HTTP 状态401。'
""", Long.class);
org.assertj.core.api.Assertions.assertThat(linkedFailedRunCount).isEqualTo(1L);
}

View File

@@ -446,6 +446,160 @@ class SuperAgentTaskResultControllerTest {
org.assertj.core.api.Assertions.assertThat(queueParticipationCount).isEqualTo(1L);
}
@Test
void shouldCreateReadOnlySourceMessageOnlyTaskForS000TextResult() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-s000-entry-result-001");
String body = "S000,mail-s000-entry-result-001";
MvcResult result = mockMvc.perform(signedPlainPost(body, "nonce-s000-entry-result-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.source_message_id").value("mail-s000-entry-result-001"))
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].source_event_index").value(1))
.andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"))
.andExpect(jsonPath("$.items[0].task_card_type").value("SOURCE_MESSAGE_ONLY"))
.andExpect(jsonPath("$.items[0].task_status").value("COMPLETED"))
.andExpect(jsonPath("$.items[0].order_status").value("TEMPORARY"))
.andExpect(content().string(not(containsString(SECRET))))
.andReturn();
String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id");
String orderId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].order_id");
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.system_task_type").value("SOURCE_MESSAGE_ONLY"))
.andExpect(jsonPath("$.task_card_type").value("SOURCE_MESSAGE_ONLY"))
.andExpect(jsonPath("$.task_status").value("COMPLETED"))
.andExpect(jsonPath("$.availability.read_only").value(true))
.andExpect(jsonPath("$.availability.editable").value(false))
.andExpect(jsonPath("$.availability.confirmable").value(false))
.andExpect(jsonPath("$.availability.executable").value(false))
.andExpect(jsonPath("$.source_message_only_result.entry_result_code").value("S000"))
.andExpect(jsonPath("$.source_message_only_result.entry_result_meaning").value("PURE_INFORMATION"))
.andExpect(jsonPath("$.source_message_only_result.entry_result_source_message_id")
.value("mail-s000-entry-result-001"))
.andExpect(jsonPath("$.source_message_only_result.raw_answer").value(body))
.andExpect(jsonPath("$.fields.length()").value(0))
.andExpect(jsonPath("$.opera_operations.length()").value(0));
mockMvc.perform(put("/api/reservation/tasks/{taskId}/draft", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"field_values": {}
}
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error_code").value("TASK_STATUS_NOT_EDITABLE"));
mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-conversions", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"target_task_type": "NEW_BOOKING",
"reason": "特殊入口结果不允许人工转换"
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("TASK_NOT_MANUAL_REVIEW"));
mockMvc.perform(get("/api/reservation/tasks")
.param("hotel_id", "HOTEL-TEST")
.param("task_type", "SOURCE_MESSAGE_ONLY")
.param("keyword", "mail-s000-entry-result-001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items[0].task_id").value(taskId))
.andExpect(jsonPath("$.items[0].task_type").value("SOURCE_MESSAGE_ONLY"))
.andExpect(jsonPath("$.items[0].card_name").value("SOURCE_MESSAGE_ONLY"))
.andExpect(jsonPath("$.items[0].task_subtype").value("S000"))
.andExpect(jsonPath("$.items[0].queue_participation").value(false))
.andExpect(jsonPath("$.items[0].can_process").value(false));
mockMvc.perform(get("/api/reservation/orders")
.param("hotel_id", "HOTEL-TEST")
.param("keyword", "mail-s000-entry-result-001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.page.total").value(0));
Long hiddenOrderCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_order
WHERE id = ?
AND order_visibility = 'HIDDEN_SYSTEM'
""", Long.class, Long.valueOf(orderId));
Long sourceOnlyTaskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE id = ?
AND source_message_id = ?
AND system_task_type = 'SOURCE_MESSAGE_ONLY'
AND task_card_type = 'SOURCE_MESSAGE_ONLY'
AND task_subtype = 'S000'
AND queue_participation = 0
AND task_status = 'COMPLETED'
""", Long.class, Long.valueOf(taskId), source.inboxId());
assertThat(hiddenOrderCount).isEqualTo(1L);
assertThat(sourceOnlyTaskCount).isEqualTo(1L);
}
@Test
void shouldCreateReadOnlySourceMessageOnlyTaskForS999TextResult() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-s999-entry-result-001");
String body = "S999,mail-s999-entry-result-001";
MvcResult result = mockMvc.perform(signedPlainPost(body, "nonce-s999-entry-result-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.source_message_id").value("mail-s999-entry-result-001"))
.andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"))
.andExpect(jsonPath("$.items[0].task_card_type").value("SOURCE_MESSAGE_ONLY"))
.andExpect(jsonPath("$.items[0].task_status").value("COMPLETED"))
.andReturn();
String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id");
Long sourceOnlyTaskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE id = ?
AND source_message_id = ?
AND system_task_type = 'SOURCE_MESSAGE_ONLY'
AND task_subtype = 'S999'
AND queue_participation = 0
AND task_status = 'COMPLETED'
""", Long.class, Long.valueOf(taskId), source.inboxId());
assertThat(sourceOnlyTaskCount).isEqualTo(1L);
}
@Test
void shouldReturnIdempotentReplayForSameS000TextResultWithNewNonce() throws Exception {
captureSourceMessage("mail-s000-idempotent-001");
String body = "S000,mail-s000-idempotent-001";
mockMvc.perform(signedPlainPost(body, "nonce-s000-idempotent-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.idempotent_replay").value(false));
mockMvc.perform(signedPlainPost(body, "nonce-s000-idempotent-002"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.idempotent_replay").value(true))
.andExpect(jsonPath("$.warnings[0].code").value("IDEMPOTENT_REPLAY"));
}
@Test
void shouldRejectTextResultWhenExternalSourceMessageIdNotFound() throws Exception {
mockMvc.perform(signedPlainPost("S000,missing-source-message-001", "nonce-s000-source-missing-001"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_NOT_FOUND"));
}
@Test
void shouldRejectUnsupportedContentTypeForTaskResultCallback() throws Exception {
String body = "S000,source-message-unsupported-content-type-001";
mockMvc.perform(signedPostWithContentType(body, "nonce-unsupported-content-type-001", MediaType.APPLICATION_XML))
.andExpect(status().isUnsupportedMediaType())
.andExpect(jsonPath("$.error_code").value("REQUEST_CONTENT_TYPE_UNSUPPORTED"));
}
@Test
void shouldMarkLaterTaskReadOnlyUntilPreviousQueueTaskIsCompletedOrFailed() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-queue-readonly-001");
@@ -1130,6 +1284,26 @@ class SuperAgentTaskResultControllerTest {
return signedPostWithClientId(body, nonce, CLIENT_ID);
}
private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder signedPlainPost(
String body,
String nonce) throws Exception {
return signedPostWithContentType(body, nonce, MediaType.TEXT_PLAIN);
}
private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder signedPostWithContentType(
String body,
String nonce,
MediaType contentType) throws Exception {
String timestamp = Instant.now().toString();
return post(ENDPOINT)
.contentType(contentType)
.content(body)
.header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
.header("X-TH-Hotel-SuperAgent-Timestamp", timestamp)
.header("X-TH-Hotel-SuperAgent-Nonce", nonce)
.header("X-TH-Hotel-SuperAgent-Signature", signature(body, nonce, timestamp, CLIENT_ID));
}
private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder signedPostWithClientId(
String body,
String nonce,