完善 Debug EML 上传调试链路

This commit is contained in:
andy
2026-07-09 15:25:26 +08:00
parent fcb30d460a
commit 6d2b3e8ccd
13 changed files with 615 additions and 56 deletions

View File

@@ -5,6 +5,7 @@ import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.not;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
@@ -99,13 +100,18 @@ class DebugEmlSuperAgentControllerTest {
.andExpect(jsonPath("$.debug_run_id").isNotEmpty())
.andExpect(jsonPath("$.source_message_id").isNotEmpty())
.andExpect(jsonPath("$.source_provider").value("DEBUG_EML_UPLOAD"))
.andExpect(jsonPath("$.external_message_id").value("debug-controller-message-001@example.test"))
.andExpect(jsonPath("$.external_message_id", containsString("debug-eml-run-")))
.andExpect(jsonPath("$.original_eml_oss_url", containsString("/raw/debug-booking.eml")))
.andExpect(jsonPath("$.uploaded_media", hasSize(greaterThanOrEqualTo(3))))
.andExpect(jsonPath("$.html_body_with_oss_urls", containsString("https://oss.example.test/")))
.andExpect(jsonPath("$.html_body_with_oss_urls", not(containsString("cid:inline-001"))))
.andExpect(jsonPath("$.html_body_sanitized", containsString("https://oss.example.test/")))
.andExpect(jsonPath("$.html_sanitize_required").value(true))
.andExpect(jsonPath("$.html_render_mode").value("SANITIZED_HTML"))
.andExpect(jsonPath("$.agentbus_like_payload.schema_version").value("debug-eml-upload-v1"))
.andExpect(jsonPath("$.agentbus_like_payload.source.provider").value("DEBUG_EML_UPLOAD"))
.andExpect(jsonPath("$.agentbus_like_payload.source.original_message_id")
.value("debug-controller-message-001@example.test"))
.andExpect(jsonPath("$.agentbus_like_payload.reply_policy.mode").value("debug_only"))
.andExpect(jsonPath("$.superagent_session_id").value("session-debug-001"))
.andExpect(jsonPath("$.superagent_run_id").value("run-debug-001"))
@@ -122,6 +128,7 @@ class DebugEmlSuperAgentControllerTest {
AND inbox.provider = 'DEBUG_EML_UPLOAD'
AND inbox.channel = 'EMAIL'
AND payload.schema_version = 'debug-eml-upload-v1'
AND payload.payload_json LIKE '%controller-test%'
""", Long.class);
org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(1L);
@@ -129,8 +136,10 @@ class DebugEmlSuperAgentControllerTest {
SELECT COUNT(*)
FROM platform_source_message_media media
JOIN platform_source_message_inbox inbox ON inbox.id = media.inbox_id
JOIN platform_source_message_payload payload ON payload.inbox_id = inbox.id
WHERE inbox.provider = 'DEBUG_EML_UPLOAD'
AND media.media_type = 'ORIGINAL_EMAIL'
AND payload.payload_json LIKE '%controller-test%'
""", Long.class);
org.assertj.core.api.Assertions.assertThat(originalEmailMediaCount).isEqualTo(1L);
@@ -141,10 +150,68 @@ class DebugEmlSuperAgentControllerTest {
AND run_status = 'SUPERAGENT_SUCCEEDED'
AND superagent_session_id = 'session-debug-001'
AND superagent_run_id = 'run-debug-001'
AND run_label = 'controller-test'
""", Long.class);
org.assertj.core.api.Assertions.assertThat(debugRunCount).isEqualTo(1L);
}
@Test
void shouldCreateIndependentSourceMessageForRepeatedDebugUpload() throws Exception {
mockStorageAndSuperAgentSuccess();
mockMvc.perform(multipart(ENDPOINT)
.file(emlFile())
.param("hotel_id", "HOTEL-TEST")
.param("run_label", "repeat-debug-upload")
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.external_message_id", containsString("debug-eml-run-")));
reset(objectStorageService, superAgentOpenApiClient);
mockStorageAndSuperAgentSuccess();
mockMvc.perform(multipart(ENDPOINT)
.file(emlFile())
.param("hotel_id", "HOTEL-TEST")
.param("run_label", "repeat-debug-upload")
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.external_message_id", containsString("debug-eml-run-")));
Long sourceCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM platform_source_message_inbox inbox
JOIN platform_source_message_payload payload ON payload.inbox_id = inbox.id
WHERE inbox.hotel_id = 'HOTEL-TEST'
AND inbox.provider = 'DEBUG_EML_UPLOAD'
AND inbox.channel = 'EMAIL'
AND inbox.external_message_id LIKE 'debug-eml-run-%'
AND payload.payload_json LIKE '%repeat-debug-upload%'
""", Long.class);
org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(2L);
}
@Test
void shouldSanitizeDebugHtmlAndReplaceUpperCaseCidReferences() throws Exception {
mockStorageAndSuperAgentSuccess();
mockMvc.perform(multipart(ENDPOINT)
.file(unsafeHtmlEmlFile())
.param("hotel_id", "HOTEL-TEST")
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.html_body_with_oss_urls", containsString("https://oss.example.test/")))
.andExpect(jsonPath("$.html_body_with_oss_urls", not(containsString("CID:inline-001"))))
.andExpect(jsonPath("$.html_body_with_oss_urls", not(containsString("cid:%3Cinline-001%3E"))))
.andExpect(jsonPath("$.html_body_with_oss_urls", not(containsString("cid:%3Cinline%2Bplus%3E"))))
.andExpect(jsonPath("$.html_body_sanitized", not(containsString("<script"))))
.andExpect(jsonPath("$.html_body_sanitized", not(containsString("onerror"))))
.andExpect(jsonPath("$.html_body_sanitized", not(containsString("javascript:"))))
.andExpect(jsonPath("$.html_body_sanitized", containsString("https://oss.example.test/")))
.andExpect(jsonPath("$.html_sanitize_required").value(true))
.andExpect(jsonPath("$.html_render_mode").value("SANITIZED_HTML"));
}
@Test
void shouldKeepCapturedSourceMessageWhenSuperAgentFails() throws Exception {
when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
@@ -179,6 +246,28 @@ class DebugEmlSuperAgentControllerTest {
org.assertj.core.api.Assertions.assertThat(linkedFailedRunCount).isEqualTo(1L);
}
private void mockStorageAndSuperAgentSuccess() {
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-001",
"run-debug-001",
"profile-debug",
"profile-version-debug",
"debug-model",
"{\"ai_task_results\":[{\"task_type\":\"New Booking\"}]}",
11,
7,
18,
List.of("metadata", "values", "end")));
}
private MockMultipartFile emlFile() {
return new MockMultipartFile(
"file",
@@ -187,6 +276,14 @@ class DebugEmlSuperAgentControllerTest {
emlBytes());
}
private MockMultipartFile unsafeHtmlEmlFile() {
return new MockMultipartFile(
"file",
"unsafe-debug-booking.eml",
MediaType.TEXT_PLAIN_VALUE,
unsafeHtmlEmlBytes());
}
private byte[] emlBytes() {
return """
From: Guest <guest@example.test>
@@ -227,4 +324,37 @@ class DebugEmlSuperAgentControllerTest {
--rel-boundary--
""".replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
}
private byte[] unsafeHtmlEmlBytes() {
return """
From: Guest <guest@example.test>
To: Reservations <reservations@example.test>
Subject: Unsafe Debug Booking
Date: Thu, 09 Jul 2026 01:30:00 +0000
Message-ID: <unsafe-debug-controller-message-001@example.test>
MIME-Version: 1.0
Content-Type: multipart/related; boundary="rel-boundary"
--rel-boundary
Content-Type: text/html; charset=UTF-8
<html><body><img src="CID:inline-001" onerror="alert(1)"><img src="cid:%3Cinline-001%3E"><img src="cid:%3Cinline%2Bplus%3E"><a href="javascript:alert(2)">bad</a><script>alert(3)</script></body></html>
--rel-boundary
Content-Type: image/png; name="inline.png"
Content-Transfer-Encoding: base64
Content-ID: <inline-001>
Content-Disposition: inline; filename="inline.png"
aW5saW5lLWltYWdl
--rel-boundary
Content-Type: image/png; name="inline-plus.png"
Content-Transfer-Encoding: base64
Content-ID: <inline+plus>
Content-Disposition: inline; filename="inline-plus.png"
aW5saW5lLXBsdXM=
--rel-boundary--
""".replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
}
}

View File

@@ -40,6 +40,15 @@ class EmlMessageParseServiceImplTest {
assertThat(attachment.bytes()).isEqualTo("pdf-content".getBytes(StandardCharsets.UTF_8));
}
@Test
void shouldUseThreadIndexAsConversationIdWhenReplyHeadersMissing() {
ParsedEmlMessage message = parseService.parse(threadIndexEmlBytes(), "thread-index.eml");
assertThat(message.messageId()).isEqualTo("thread-index-message-001@example.test");
assertThat(message.conversationId()).isEqualTo("AcvThreadIndexDebug001");
assertThat(message.textBody()).contains("Thread index body");
}
private byte[] emlBytes() {
return """
From: Guest <guest@example.test>
@@ -80,4 +89,19 @@ class EmlMessageParseServiceImplTest {
--rel-boundary--
""".replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
}
private byte[] threadIndexEmlBytes() {
return """
From: Guest <guest@example.test>
To: Reservations <reservations@example.test>
Subject: Thread Index Booking Request
Date: Thu, 09 Jul 2026 01:30:00 +0000
Message-ID: <thread-index-message-001@example.test>
Thread-Index: AcvThreadIndexDebug001
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Thread index body
""".replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
}
}