兼容Debug EML SSE断流兜底

This commit is contained in:
andy
2026-07-12 19:26:52 +08:00
parent 9d095cfd53
commit 807d045526
17 changed files with 556 additions and 47 deletions

View File

@@ -93,16 +93,16 @@ public class SuperAgentOpenApiSseParser {
* 汇总解析状态并生成结果对象。
*/
private SuperAgentOpenApiResult buildResult(String sessionId, Set<String> eventTypes, ParsedState state) {
if (!state.endSeen) {
throw new SuperAgentOpenApiException("SuperAgent SSE 未收到结束事件。");
}
if ((state.rawAnswer == null || state.rawAnswer.isBlank()) && state.fallbackRawAnswer != null) {
if ((state.rawAnswer == null || state.rawAnswer.isBlank()) && state.endSeen && state.fallbackRawAnswer != null) {
state.rawAnswer = state.fallbackRawAnswer;
state.modelName = state.fallbackModelName;
state.inputTokens = state.fallbackInputTokens;
state.outputTokens = state.fallbackOutputTokens;
state.totalTokens = state.fallbackTotalTokens;
}
if (!state.endSeen && (state.rawAnswer == null || state.rawAnswer.isBlank())) {
throw new SuperAgentOpenApiException("SuperAgent SSE 未收到结束事件。");
}
if (state.rawAnswer == null || state.rawAnswer.isBlank()) {
throw new SuperAgentOpenApiException("SuperAgent SSE 未找到最终 AI 回答。");
}

View File

@@ -9,13 +9,49 @@ import java.time.LocalDateTime;
* @param hotelId 酒店或业务上下文 ID
* @param runStatus 当前运行状态
* @param sourceMessageId 关联的内部 SourceMessage ID
* @param externalMessageId Debug 外部邮件 ID
* @param externalConversationId Debug 外部会话 ID
* @param originalFileName 原始 EML 安全文件名
* @param originalEmlOssUrl 原始 EML OSS URL
* @param originalEmlSha256 原始 EML SHA-256
* @param payloadJson 发送给 SuperAgent 的 AgentBus-like payload
* @param superagentSessionId SuperAgent session ID
* @param superagentRunId SuperAgent run ID
* @param superagentProfileId SuperAgent profile ID
* @param superagentProfileVersionId SuperAgent profile version ID
* @param superagentModelName SuperAgent 模型名称
* @param superagentRawAnswer SuperAgent 最终原始回答
* @param superagentParsedJson 后端解析出的 SuperAgent JSON 文本
* @param superagentInputTokens SuperAgent 输入 token 数
* @param superagentOutputTokens SuperAgent 输出 token 数
* @param superagentTotalTokens SuperAgent 总 token 数
* @param safeErrorSummary 安全错误摘要
* @param createdAt 创建 UTC 时间
* @param updatedAt 更新 UTC 时间
*/
public record DebugEmlSuperAgentRunSnapshot(
Long id,
String hotelId,
String runStatus,
Long sourceMessageId,
LocalDateTime createdAt
String externalMessageId,
String externalConversationId,
String originalFileName,
String originalEmlOssUrl,
String originalEmlSha256,
String payloadJson,
String superagentSessionId,
String superagentRunId,
String superagentProfileId,
String superagentProfileVersionId,
String superagentModelName,
String superagentRawAnswer,
String superagentParsedJson,
Integer superagentInputTokens,
Integer superagentOutputTokens,
Integer superagentTotalTokens,
String safeErrorSummary,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -29,6 +29,7 @@ import java.util.Map;
* @param superagentTraceEvents SuperAgent 公开 Trace 事件列表
* @param warnings 可展示的安全警告
* @param status Debug 运行状态
* @param safeErrorSummary 安全错误摘要,成功时为空;不包含 Secret、邮件正文或附件签名 URL
*/
public record DebugEmlSuperAgentRunResult(
@JsonProperty("debug_run_id")
@@ -68,6 +69,8 @@ public record DebugEmlSuperAgentRunResult(
@JsonProperty("superagent_trace_events")
List<SuperAgentOpenApiTraceEvent> superagentTraceEvents,
List<String> warnings,
String status
String status,
@JsonProperty("safe_error_summary")
String safeErrorSummary
) {
}

View File

@@ -7,6 +7,8 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -44,6 +46,16 @@ public class DebugEmlSuperAgentController {
return ResponseEntity.status(HttpStatus.CREATED).body(runService.uploadAndRun(accessKey, file, hotelId, runLabel));
}
/**
* 查询 Debug EML 运行结果,用于流式响应提前结束后的前端兜底轮询。
*/
@GetMapping(path = "/{runId}", produces = MediaType.APPLICATION_JSON_VALUE)
public DebugEmlSuperAgentRunResult getRun(
@RequestHeader(name = "X-TH-Hotel-Debug-Upload-Key", required = false) String accessKey,
@PathVariable String runId) {
return runService.getRun(accessKey, runId);
}
/**
* 上传单封 .eml 邮件并以 SSE 实时返回本系统阶段、SuperAgent 公开 Trace 和最终回答。
*/

View File

@@ -100,6 +100,24 @@ public class MybatisDebugEmlSuperAgentRunRepository implements DebugEmlSuperAgen
entity.getHotelId(),
entity.getRunStatus(),
entity.getSourceMessageId(),
entity.getCreatedAt());
entity.getExternalMessageId(),
entity.getExternalConversationId(),
entity.getOriginalFileName(),
entity.getOriginalEmlOssUrl(),
entity.getOriginalEmlSha256(),
entity.getPayloadJson(),
entity.getSuperagentSessionId(),
entity.getSuperagentRunId(),
entity.getSuperagentProfileId(),
entity.getSuperagentProfileVersionId(),
entity.getSuperagentModelName(),
entity.getSuperagentRawAnswer(),
entity.getSuperagentParsedJson(),
entity.getSuperagentInputTokens(),
entity.getSuperagentOutputTokens(),
entity.getSuperagentTotalTokens(),
entity.getSafeErrorSummary(),
entity.getCreatedAt(),
entity.getUpdatedAt());
}
}

View File

@@ -23,6 +23,11 @@ public interface DebugEmlSuperAgentRunService {
String hotelId,
String runLabel);
/**
* 查询 Debug EML 运行安全结果,用于流式响应被中间链路提前关闭后的前端兜底轮询。
*/
DebugEmlSuperAgentRunResult getRun(String accessKey, String runId);
/**
* 上传并处理单封 EML按 text/event-stream 实时写出内部阶段、SuperAgent Trace 和最终结果。
*/

View File

@@ -12,6 +12,7 @@ import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.impl.AliyunOssPr
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.DebugEmlSuperAgentRunSnapshot;
import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunStatusUpdate;
import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunUpdate;
import cn.nianxx.thhotel.platform.debug.common.enums.DebugEmlSuperAgentRunStatus;
@@ -23,15 +24,19 @@ import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMediaItem;
import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMessage;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalContent;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalMediaItem;
import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageMediaType;
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository;
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.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -97,6 +102,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
private final EmlMessageParseService parseService;
private final ObjectStorageService objectStorageService;
private final SourceMessageCaptureService sourceMessageCaptureService;
private final SourceMessageInboxRepository sourceMessageInboxRepository;
private final SourceMessageHtmlSanitizerService htmlSanitizerService;
private final SuperAgentOpenApiClient superAgentOpenApiClient;
private final DebugEmlSuperAgentRunRepository runRepository;
@@ -112,6 +118,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
EmlMessageParseService parseService,
ObjectStorageService objectStorageService,
SourceMessageCaptureService sourceMessageCaptureService,
SourceMessageInboxRepository sourceMessageInboxRepository,
SourceMessageHtmlSanitizerService htmlSanitizerService,
SuperAgentOpenApiClient superAgentOpenApiClient,
DebugEmlSuperAgentRunRepository runRepository,
@@ -122,6 +129,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
this.parseService = parseService;
this.objectStorageService = objectStorageService;
this.sourceMessageCaptureService = sourceMessageCaptureService;
this.sourceMessageInboxRepository = sourceMessageInboxRepository;
this.htmlSanitizerService = htmlSanitizerService;
this.superAgentOpenApiClient = superAgentOpenApiClient;
this.runRepository = runRepository;
@@ -199,6 +207,21 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
}
}
/**
* 查询 Debug EML 运行安全结果。用于前端 SSE 被中间链路提前关闭后按 runId 兜底轮询。
*/
@Override
public DebugEmlSuperAgentRunResult getRun(String accessKey, String runId) {
validateAccessKey(accessKey);
Long parsedRunId = parseRunId(runId);
DebugEmlSuperAgentRunSnapshot snapshot = runRepository.findById(parsedRunId)
.orElseThrow(() -> new DebugEmlSuperAgentException(
HttpStatus.NOT_FOUND,
"DEBUG_EML_RUN_NOT_FOUND",
"Debug EML 运行记录不存在。"));
return toRunResult(snapshot);
}
/**
* 处理单封 Debug EML 上传,并实时输出安全的调试 SSE 事件。
*/
@@ -571,7 +594,131 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
parsedJson,
traceEvents,
List.copyOf(warnings),
status.name());
status.name(),
null);
}
/**
* 将持久化快照转换为 Debug 页面可安全展示的查询结果。
*/
private DebugEmlSuperAgentRunResult toRunResult(DebugEmlSuperAgentRunSnapshot snapshot) {
SourceMessageOriginalContent originalContent = loadOriginalContent(snapshot.sourceMessageId());
String htmlBodyWithOssUrls = originalContent == null ? null : originalContent.htmlBody();
String htmlBodySanitized = htmlBodyWithOssUrls == null ? null : htmlSanitizerService.sanitizeHtml(htmlBodyWithOssUrls);
return new DebugEmlSuperAgentRunResult(
snapshot.id().toString(),
snapshot.sourceMessageId() == null ? null : snapshot.sourceMessageId().toString(),
snapshot.sourceMessageId() == null ? null : SOURCE_PROVIDER,
snapshot.externalMessageId(),
snapshot.externalConversationId(),
snapshot.originalEmlOssUrl(),
snapshot.originalEmlSha256(),
originalContent == null ? List.of() : debugMediaResults(originalContent.mediaItems()),
htmlBodyWithOssUrls,
htmlBodySanitized,
originalContent == null ? null : true,
originalContent == null ? null : htmlSanitizerService.htmlRenderMode(htmlBodyWithOssUrls),
parsePayloadJson(snapshot.payloadJson()),
snapshot.superagentSessionId(),
snapshot.superagentRunId(),
snapshot.superagentRawAnswer(),
parseJsonNode(snapshot.superagentParsedJson()),
List.of(),
List.of(),
snapshot.runStatus(),
snapshot.safeErrorSummary());
}
/**
* 读取 Debug run 关联 SourceMessage 的受控原文快照;缺失时返回 null查询接口仍返回运行状态。
*/
private SourceMessageOriginalContent loadOriginalContent(Long sourceMessageId) {
if (sourceMessageId == null) {
return null;
}
return sourceMessageInboxRepository.findOriginalContent(sourceMessageId).orElse(null);
}
/**
* 将 SourceMessage 媒体快照转换为 Debug 响应媒体项。
*/
private List<DebugEmlUploadedMediaResult> debugMediaResults(List<SourceMessageOriginalMediaItem> mediaItems) {
if (mediaItems == null || mediaItems.isEmpty()) {
return List.of();
}
return mediaItems.stream()
.map(item -> new DebugEmlUploadedMediaResult(
item.mediaType(),
item.fileName(),
item.contentType(),
item.sizeBytes(),
item.externalUrl(),
item.externalMediaId(),
objectKeyFromPublicUrl(item.externalUrl())))
.toList();
}
/**
* 尽量从公开 URL 还原 OSS object key无法可靠还原时返回 null不影响前端调试展示。
*/
private String objectKeyFromPublicUrl(String externalUrl) {
String normalizedUrl = trimToNull(externalUrl);
String publicBaseUrl = trimTrailingSlash(trimToNull(ossProperties.getPublicBaseUrl()));
if (normalizedUrl == null || publicBaseUrl == null || !normalizedUrl.startsWith(publicBaseUrl + "/")) {
return null;
}
String encodedObjectKey = normalizedUrl.substring(publicBaseUrl.length() + 1);
return URLDecoder.decode(encodedObjectKey, StandardCharsets.UTF_8);
}
/**
* 解析查询路径中的 Debug runId。
*/
private Long parseRunId(String runId) {
String normalizedRunId = trimToNull(runId);
if (normalizedRunId == null) {
throw new DebugEmlSuperAgentException(HttpStatus.BAD_REQUEST, "REQUEST_FIELD_REQUIRED", "debug_run_id 不能为空。");
}
try {
return Long.valueOf(normalizedRunId);
} catch (NumberFormatException exception) {
throw new DebugEmlSuperAgentException(HttpStatus.BAD_REQUEST, "REQUEST_FIELD_INVALID", "debug_run_id 格式错误。");
}
}
/**
* 解析已入库的 payload JSON解析失败返回 null避免查询接口抛出内部异常。
*/
private Map<String, Object> parsePayloadJson(String payloadJson) {
String normalizedPayload = trimToNull(payloadJson);
if (normalizedPayload == null) {
return null;
}
try {
JsonNode jsonNode = objectMapper.readTree(normalizedPayload);
if (jsonNode == null || !jsonNode.isObject()) {
return null;
}
return objectMapper.convertValue(jsonNode, new TypeReference<>() {
});
} catch (Exception exception) {
return null;
}
}
/**
* 解析已入库的 SuperAgent JSON解析失败返回 null前端仍可查看 raw answer。
*/
private JsonNode parseJsonNode(String jsonText) {
String normalizedJson = trimToNull(jsonText);
if (normalizedJson == null) {
return null;
}
try {
return objectMapper.readTree(normalizedJson);
} catch (Exception exception) {
return null;
}
}
/**
@@ -1135,6 +1282,20 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
return trimmed.isEmpty() ? null : trimmed;
}
/**
* 去掉 URL 末尾斜杠,便于用公开基础 URL 还原对象路径。
*/
private String trimTrailingSlash(String value) {
if (value == null) {
return null;
}
String trimmed = value;
while (trimmed.endsWith("/")) {
trimmed = trimmed.substring(0, trimmed.length() - 1);
}
return trimmed;
}
/**
* 截断安全摘要,避免超过数据库限制。
*/

View File

@@ -11,6 +11,10 @@ spring:
# multipart 需要高于 Debug EML 业务文件上限,避免超限文件在进入 Controller 前被框架直接 413 拦截。
max-file-size: ${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520}
max-request-size: ${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}}
mvc:
async:
# Spring MVC async timeout 是应用级全局值;当前主要用于避免 Debug EML SSE 先于 SuperAgent 调试调用关闭。
request-timeout: ${DEBUG_EML_UPLOAD_SSE_REQUEST_TIMEOUT:${SPRING_MVC_ASYNC_REQUEST_TIMEOUT:1800s}}
mybatis-plus:
configuration:

View File

@@ -89,16 +89,30 @@ class SuperAgentOpenApiSseParserTest {
}
@Test
void shouldFailWhenEndEventMissingEvenIfAiContentExists() {
void shouldFailWhenEndEventMissingAndOnlyPartialAiContentExists() {
String sse = """
event: messages
data: {"type":"ai","content":"partial answer"}
""";
assertThatThrownBy(() -> parser.parse("session-debug-partial", sse))
.isInstanceOf(SuperAgentOpenApiException.class)
.hasMessageContaining("SuperAgent SSE 未收到结束事件。");
}
@Test
void shouldAcceptMissingEndEventWhenFinalAiContentExists() {
String sse = """
event: messages
data: {"type":"ai","content":"{\\"ai_task_results\\":[]}","response_metadata":{"finish_reason":"stop"}}
""";
assertThatThrownBy(() -> parser.parse("session-debug-004", sse))
.isInstanceOf(SuperAgentOpenApiException.class)
.hasMessageContaining("SuperAgent SSE 未收到结束事件。");
SuperAgentOpenApiResult result = parser.parse("session-debug-004", sse);
assertThat(result.rawAnswer()).isEqualTo("{\"ai_task_results\":[]}");
assertThat(result.eventTypes()).containsExactly("messages");
}
@Test

View File

@@ -8,6 +8,7 @@ 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.asyncDispatch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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;
@@ -186,6 +187,44 @@ class DebugEmlSuperAgentControllerTest {
org.assertj.core.api.Assertions.assertThat(debugRunCount).isEqualTo(1L);
}
@Test
void shouldQueryDebugRunResultByIdForStreamFallback() throws Exception {
mockStorageAndSuperAgentSuccess();
mockMvc.perform(multipart(ENDPOINT)
.file(emlFile())
.param("hotel_id", "HOTEL-TEST")
.param("run_label", "query-run-fallback")
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
.andExpect(status().isCreated());
Long runId = jdbcTemplate.queryForObject("""
SELECT id
FROM platform_debug_eml_superagent_run
WHERE run_label = 'query-run-fallback'
""", Long.class);
mockMvc.perform(get(ENDPOINT + "/" + runId)
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.debug_run_id").value(runId.toString()))
.andExpect(jsonPath("$.source_message_id").isNotEmpty())
.andExpect(jsonPath("$.source_provider").value("DEBUG_EML_UPLOAD"))
.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("$.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(jsonPath("$.safe_error_summary").doesNotExist())
.andExpect(content().string(not(containsString("test-debug-upload-key"))));
}
@Test
void shouldStreamDebugStagesSuperAgentTraceAndFinalResult() throws Exception {
when(objectStorageService.putObject(any())).thenAnswer(invocation -> {