实现 Debug EML 上传到 SuperAgent 链路
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
package cn.nianxx.thhotel.integrations.ai.superagent.service.impl;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SuperAgentOpenApiSseParserTest {
|
||||
|
||||
private final SuperAgentOpenApiSseParser parser = new SuperAgentOpenApiSseParser();
|
||||
|
||||
@Test
|
||||
void shouldExtractFinalAiAnswerFromValuesEvent() {
|
||||
String sse = """
|
||||
event: metadata
|
||||
data: {"run_id":"run-debug-001","resolved_profile_id":"profile-debug"}
|
||||
|
||||
event: messages
|
||||
data: {"type":"ai","content":"partial answer"}
|
||||
|
||||
event: values
|
||||
data: {"messages":[{"type":"human","content":"input"},{"type":"ai","content":"{\\"ai_task_results\\":[{\\"task_type\\":\\"New Booking\\"}]}","response_metadata":{"finish_reason":"stop","model_name":"debug-model"},"usage_metadata":{"input_tokens":11,"output_tokens":7,"total_tokens":18}}]}
|
||||
|
||||
event: end
|
||||
data: {}
|
||||
|
||||
""";
|
||||
|
||||
SuperAgentOpenApiResult result = parser.parse("session-debug-001", sse);
|
||||
|
||||
assertThat(result.sessionId()).isEqualTo("session-debug-001");
|
||||
assertThat(result.runId()).isEqualTo("run-debug-001");
|
||||
assertThat(result.profileId()).isEqualTo("profile-debug");
|
||||
assertThat(result.rawAnswer()).isEqualTo("{\"ai_task_results\":[{\"task_type\":\"New Booking\"}]}");
|
||||
assertThat(result.modelName()).isEqualTo("debug-model");
|
||||
assertThat(result.inputTokens()).isEqualTo(11);
|
||||
assertThat(result.outputTokens()).isEqualTo(7);
|
||||
assertThat(result.totalTokens()).isEqualTo(18);
|
||||
assertThat(result.eventTypes()).containsExactly("metadata", "messages", "values", "end");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package cn.nianxx.thhotel.platform.debug.control;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
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.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import cn.nianxx.thhotel.ThHotelApplication;
|
||||
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult;
|
||||
import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentOpenApiClient;
|
||||
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentOpenApiException;
|
||||
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest;
|
||||
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult;
|
||||
import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
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;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = ThHotelApplication.class,
|
||||
properties = {
|
||||
"debug.eml-upload.enabled=true",
|
||||
"debug.eml-upload.access-key=test-debug-upload-key",
|
||||
"debug.eml-upload.max-file-bytes=1048576",
|
||||
"aliyun.oss.debug-eml-prefix=debug/eml/",
|
||||
"superagent.open-api.enabled=true",
|
||||
"superagent.open-api.external-subject-id=test-debug-eml"
|
||||
})
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class DebugEmlSuperAgentControllerTest {
|
||||
|
||||
private static final String ENDPOINT = "/api/system/debug/eml-superagent-runs";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@MockBean
|
||||
private ObjectStorageService objectStorageService;
|
||||
|
||||
@MockBean
|
||||
private SuperAgentOpenApiClient superAgentOpenApiClient;
|
||||
|
||||
@Test
|
||||
void shouldRejectUploadWhenDebugKeyMissing() throws Exception {
|
||||
mockMvc.perform(multipart(ENDPOINT)
|
||||
.file(emlFile())
|
||||
.param("hotel_id", "HOTEL-TEST"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(content().string(not(containsString("test-debug-upload-key"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUploadEmlToOssCaptureSourceMessageAndReturnSuperAgentResult() 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-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")));
|
||||
|
||||
mockMvc.perform(multipart(ENDPOINT)
|
||||
.file(emlFile())
|
||||
.param("hotel_id", "HOTEL-TEST")
|
||||
.param("run_label", "controller-test")
|
||||
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
|
||||
.andExpect(status().isCreated())
|
||||
.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("$.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("$.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.reply_policy.mode").value("debug_only"))
|
||||
.andExpect(jsonPath("$.superagent_session_id").value("session-debug-001"))
|
||||
.andExpect(jsonPath("$.superagent_run_id").value("run-debug-001"))
|
||||
.andExpect(jsonPath("$.superagent_raw_answer", containsString("ai_task_results")))
|
||||
.andExpect(jsonPath("$.superagent_parsed_json.ai_task_results[0].task_type").value("New Booking"))
|
||||
.andExpect(jsonPath("$.status").value("SUPERAGENT_SUCCEEDED"))
|
||||
.andExpect(content().string(not(containsString("test-debug-upload-key"))));
|
||||
|
||||
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 payload.schema_version = 'debug-eml-upload-v1'
|
||||
""", Long.class);
|
||||
org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(1L);
|
||||
|
||||
Long originalEmailMediaCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM platform_source_message_media media
|
||||
JOIN platform_source_message_inbox inbox ON inbox.id = media.inbox_id
|
||||
WHERE inbox.provider = 'DEBUG_EML_UPLOAD'
|
||||
AND media.media_type = 'ORIGINAL_EMAIL'
|
||||
""", Long.class);
|
||||
org.assertj.core.api.Assertions.assertThat(originalEmailMediaCount).isEqualTo(1L);
|
||||
|
||||
Long debugRunCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM platform_debug_eml_superagent_run
|
||||
WHERE hotel_id = 'HOTEL-TEST'
|
||||
AND run_status = 'SUPERAGENT_SUCCEEDED'
|
||||
AND superagent_session_id = 'session-debug-001'
|
||||
AND superagent_run_id = 'run-debug-001'
|
||||
""", Long.class);
|
||||
org.assertj.core.api.Assertions.assertThat(debugRunCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepCapturedSourceMessageWhenSuperAgentFails() 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()))
|
||||
.thenThrow(new SuperAgentOpenApiException("SuperAgent Open API 调用失败。"));
|
||||
|
||||
mockMvc.perform(multipart(ENDPOINT)
|
||||
.file(emlFile())
|
||||
.param("hotel_id", "HOTEL-TEST")
|
||||
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
|
||||
.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("Please create booking"))));
|
||||
|
||||
Long linkedFailedRunCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM platform_debug_eml_superagent_run
|
||||
WHERE hotel_id = 'HOTEL-TEST'
|
||||
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 调用失败。'
|
||||
""", Long.class);
|
||||
org.assertj.core.api.Assertions.assertThat(linkedFailedRunCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
private MockMultipartFile emlFile() {
|
||||
return new MockMultipartFile(
|
||||
"file",
|
||||
"debug-booking.eml",
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
emlBytes());
|
||||
}
|
||||
|
||||
private byte[] emlBytes() {
|
||||
return """
|
||||
From: Guest <guest@example.test>
|
||||
To: Reservations <reservations@example.test>
|
||||
Subject: Debug Booking
|
||||
Date: Thu, 09 Jul 2026 01:30:00 +0000
|
||||
Message-ID: <debug-controller-message-001@example.test>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/related; boundary="rel-boundary"
|
||||
|
||||
--rel-boundary
|
||||
Content-Type: multipart/alternative; boundary="alt-boundary"
|
||||
|
||||
--alt-boundary
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
Please create booking from debug email.
|
||||
|
||||
--alt-boundary
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<html><body><p>Please create booking.</p><img src="cid:inline-001"></body></html>
|
||||
|
||||
--alt-boundary--
|
||||
--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: application/pdf; name="booking.pdf"
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-Disposition: attachment; filename="booking.pdf"
|
||||
|
||||
cGRmLWNvbnRlbnQ=
|
||||
--rel-boundary--
|
||||
""".replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package cn.nianxx.thhotel.platform.message.service.impl;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMediaItem;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMessage;
|
||||
import cn.nianxx.thhotel.platform.message.service.EmlMessageParseService;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class EmlMessageParseServiceImplTest {
|
||||
|
||||
private final EmlMessageParseService parseService = new EmlMessageParseServiceImpl();
|
||||
|
||||
@Test
|
||||
void shouldParseHeadersBodiesInlineImagesAndAttachmentsFromEml() {
|
||||
ParsedEmlMessage message = parseService.parse(emlBytes(), "booking.eml");
|
||||
|
||||
assertThat(message.messageId()).isEqualTo("debug-message-001@example.test");
|
||||
assertThat(message.conversationId()).isEqualTo("debug-message-001@example.test");
|
||||
assertThat(message.sender()).isEqualTo("guest@example.test");
|
||||
assertThat(message.subject()).isEqualTo("Booking Request");
|
||||
assertThat(message.sentAt()).isEqualTo(Instant.parse("2026-07-09T01:30:00Z"));
|
||||
assertThat(message.textBody()).contains("Plain booking body");
|
||||
assertThat(message.htmlBody()).contains("cid:inline-001");
|
||||
assertThat(message.mediaItems()).hasSize(2);
|
||||
|
||||
ParsedEmlMediaItem inlineImage = message.mediaItems().get(0);
|
||||
assertThat(inlineImage.mediaType()).isEqualTo("INLINE_IMAGE");
|
||||
assertThat(inlineImage.fileName()).isEqualTo("inline.png");
|
||||
assertThat(inlineImage.contentType()).isEqualTo("image/png");
|
||||
assertThat(inlineImage.contentId()).isEqualTo("inline-001");
|
||||
assertThat(inlineImage.bytes()).isEqualTo("inline-image".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
ParsedEmlMediaItem attachment = message.mediaItems().get(1);
|
||||
assertThat(attachment.mediaType()).isEqualTo("ATTACHMENT");
|
||||
assertThat(attachment.fileName()).isEqualTo("booking.pdf");
|
||||
assertThat(attachment.contentType()).isEqualTo("application/pdf");
|
||||
assertThat(attachment.bytes()).isEqualTo("pdf-content".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private byte[] emlBytes() {
|
||||
return """
|
||||
From: Guest <guest@example.test>
|
||||
To: Reservations <reservations@example.test>
|
||||
Subject: Booking Request
|
||||
Date: Thu, 09 Jul 2026 01:30:00 +0000
|
||||
Message-ID: <debug-message-001@example.test>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/related; boundary="rel-boundary"
|
||||
|
||||
--rel-boundary
|
||||
Content-Type: multipart/alternative; boundary="alt-boundary"
|
||||
|
||||
--alt-boundary
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
Plain booking body
|
||||
|
||||
--alt-boundary
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<html><body><p>HTML booking body</p><img src="cid:inline-001"></body></html>
|
||||
|
||||
--alt-boundary--
|
||||
--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: application/pdf; name="booking.pdf"
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-Disposition: attachment; filename="booking.pdf"
|
||||
|
||||
cGRmLWNvbnRlbnQ=
|
||||
--rel-boundary--
|
||||
""".replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user