实现SuperAgent特殊入口结果处理
This commit is contained in:
@@ -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 之前限制请求体大小,避免超限请求进入后续处理。
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 运行失败。
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.enums;
|
||||
|
||||
/**
|
||||
* AI 结果类型稳定代码。第一版只允许 normal_task、manual_review 和 informational_message。
|
||||
* AI 结构化结果类型稳定代码。新入口只使用 normal_task 和 manual_review,informational_message 仅历史兼容。
|
||||
*/
|
||||
public enum AiResultType {
|
||||
NORMAL_TASK("normal_task"),
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.enums;
|
||||
|
||||
/**
|
||||
* 订单前端可见性。用于区分真实业务订单和仅为系统任务挂靠使用的技术订单。
|
||||
*/
|
||||
public enum ReservationOrderVisibility {
|
||||
/** 普通业务订单,默认在订单列表和订单详情中可见。 */
|
||||
VISIBLE,
|
||||
/** 系统隐藏订单,仅用于 S000/S999 等来源消息结果任务挂靠。 */
|
||||
HIDDEN_SYSTEM
|
||||
}
|
||||
@@ -8,5 +8,6 @@ public enum ReservationSystemTaskType {
|
||||
UPDATE_BOOKING,
|
||||
CANCEL_BOOKING,
|
||||
MANUAL_REVIEW,
|
||||
INFORMATIONAL_MESSAGE
|
||||
INFORMATIONAL_MESSAGE,
|
||||
SOURCE_MESSAGE_ONLY
|
||||
}
|
||||
|
||||
@@ -13,5 +13,6 @@ public enum ReservationTaskCardType {
|
||||
TRACE_RESERVATION_NOTES,
|
||||
TA_RECORDER,
|
||||
MESSAGE_NOTIFICATION,
|
||||
FALLBACK_REVIEW
|
||||
FALLBACK_REVIEW,
|
||||
SOURCE_MESSAGE_ONLY
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 和最终写入的同订单执行序号。
|
||||
*/
|
||||
|
||||
@@ -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。第一版未接用户酒店上下文时使用本地默认酒店。
|
||||
*/
|
||||
|
||||
@@ -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。
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user