实现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

@@ -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,