diff --git a/client/src/services/debugEmlService.ts b/client/src/services/debugEmlService.ts index befbc2f..ddf3e63 100644 --- a/client/src/services/debugEmlService.ts +++ b/client/src/services/debugEmlService.ts @@ -109,6 +109,9 @@ export async function uploadDebugEmlSuperAgentRunStream( handlers.onTrace?.(normalizeTraceEvent(payload)) return } + if (event.event === 'debug_heartbeat') { + return + } if (event.event === 'superagent_result') { finalResult = normalizeDebugEmlResult(payload) handlers.onResult?.(finalResult) diff --git a/client/src/tests/debugEmlService.spec.ts b/client/src/tests/debugEmlService.spec.ts index bfb79e8..26e2057 100644 --- a/client/src/tests/debugEmlService.spec.ts +++ b/client/src/tests/debugEmlService.spec.ts @@ -187,6 +187,8 @@ describe('debugEmlService', () => { 'data: {"event":"reasoning.summary","run_id":"run-stream-1","text":"正在分析 Debug 邮件。","ts":"2026-07-11T10:00:01Z"}\n\n', 'event: superagent_trace\n', 'data: {"event":"tool.call.started","run_id":"run-stream-1","tool_name":"th_hotel_query_case_context","input_summary":"{\\"group_code\\":\\"G001\\"}"}\n\n', + 'event: debug_heartbeat\n', + 'data: {"debug_run_id":"90003","status":"CALLING_SUPERAGENT"}\n\n', 'event: superagent_result\n', 'data: {"debug_run_id":"90003","source_message_id":"30003","uploaded_media":[],"superagent_session_id":"session-stream-1","superagent_run_id":"run-stream-1","superagent_raw_answer":"{\\"route_code\\":\\"S10\\"}","superagent_parsed_json":{"route_code":"S10"},"superagent_trace_events":[{"event":"reasoning.summary","text":"正在分析 Debug 邮件。"}],"warnings":[],"status":"SUPERAGENT_SUCCEEDED"}\n\n', 'event: done\n', diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index 4f5e5e8..49c2c24 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -157,6 +157,7 @@ | `DEBUG_EML_UPLOAD_TEST_ACCESS_KEY` | 是 | test Debug EML 上传访问口令,未配置时可兜底 `DEBUG_EML_UPLOAD_ACCESS_KEY`。 | | `DEBUG_EML_UPLOAD_PROD_ACCESS_KEY` | 是 | prod Debug EML 上传访问口令;生产通常不应启用该接口。 | | `DEBUG_EML_UPLOAD_MAX_FILE_BYTES` | 否 | `.eml` 上传大小上限,默认 `10485760`。 | +| `DEBUG_EML_UPLOAD_SSE_HEARTBEAT_INTERVAL` | 否 | Debug EML 页面到后端的 SSE 心跳间隔,默认 `15s`;测试机如仍遇到空闲断流可调小到 `10s`。 | | `DEERFLOW_DEV_BASE_URL` / `DEERFLOW_TEST_BASE_URL` / `DEERFLOW_PROD_BASE_URL` | 否 | SuperAgent / DeerFlow Open API 基础地址,未配置时可兜底 `DEERFLOW_BASE_URL`。 | | `DEERFLOW_DEV_OPEN_API_KEY` / `DEERFLOW_TEST_OPEN_API_KEY` / `DEERFLOW_PROD_OPEN_API_KEY` | 是 | SuperAgent Open API Key,未配置时可兜底 `DEERFLOW_OPEN_API_KEY`。 | | `SUPERAGENT_DEV_OPEN_API_ENABLED` / `SUPERAGENT_TEST_OPEN_API_ENABLED` / `SUPERAGENT_PROD_OPEN_API_ENABLED` | 否 | 是否启用真实 SuperAgent Open API 调用;prod 默认关闭。 | diff --git a/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md b/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md index 81ee731..aca9b4f 100644 --- a/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md +++ b/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md @@ -425,6 +425,7 @@ integrations.ai.superagent DEBUG_EML_UPLOAD_ENABLED=true DEBUG_EML_UPLOAD_ACCESS_KEY= DEBUG_EML_UPLOAD_MAX_FILE_BYTES=10485760 +DEBUG_EML_UPLOAD_SSE_HEARTBEAT_INTERVAL=15s ALIYUN_OSS_ENDPOINT= ALIYUN_OSS_BUCKET= diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentProperties.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentProperties.java index d2ce146..2b2c4c3 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentProperties.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentProperties.java @@ -1,5 +1,6 @@ package cn.nianxx.thhotel.platform.debug.service.impl; +import java.time.Duration; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @@ -16,6 +17,8 @@ public class DebugEmlSuperAgentProperties { private String accessKey = ""; /** 允许上传的最大文件字节数。 */ private long maxFileBytes = 10 * 1024 * 1024L; + /** Debug SSE 心跳间隔,用于避免长时间调用 SuperAgent 时中间链路空闲断开。 */ + private Duration sseHeartbeatInterval = Duration.ofSeconds(15); public boolean isEnabled() { return enabled; @@ -40,4 +43,12 @@ public class DebugEmlSuperAgentProperties { public void setMaxFileBytes(long maxFileBytes) { this.maxFileBytes = maxFileBytes; } + + public Duration getSseHeartbeatInterval() { + return sseHeartbeatInterval; + } + + public void setSseHeartbeatInterval(Duration sseHeartbeatInterval) { + this.sseHeartbeatInterval = sseHeartbeatInterval; + } } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java index aedb4c1..e52e4e2 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java @@ -42,6 +42,7 @@ import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.Duration; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; @@ -51,9 +52,16 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -66,12 +74,23 @@ import org.springframework.web.client.RestClientResponseException; @Service public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunService { + private static final Logger log = LoggerFactory.getLogger(DebugEmlSuperAgentRunServiceImpl.class); private static final String SOURCE_PROVIDER = "DEBUG_EML_UPLOAD"; private static final String SOURCE_CHANNEL = "EMAIL"; private static final String SCHEMA_VERSION = "debug-eml-upload-v1"; private static final DateTimeFormatter DATE_FOLDER_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; private static final Pattern CID_REFERENCE_PATTERN = Pattern.compile( "(?i)cid:(?:<[^>]+>|%3c[^\\s\"'>]+%3e|[^\\s\"'>]+)"); + private static final Pattern JSON_SECRET_PATTERN = Pattern.compile( + "(?i)(\"(?:api[_-]?key|token|secret|password|cookie|authorization)\"\\s*:\\s*\")[^\"]*(\")"); + private static final Pattern AUTHORIZATION_BEARER_SECRET_PATTERN = Pattern.compile( + "(?i)(authorization\\s*[:=]\\s*bearer\\s+)[^\\s,;]+"); + private static final Pattern AUTHORIZATION_SECRET_PATTERN = Pattern.compile( + "(?i)(authorization\\s*[:=]\\s*)(?!bearer\\s)[^\\s,;]+"); + private static final Pattern COOKIE_SECRET_PATTERN = Pattern.compile( + "(?i)(cookie\\s*[:=]\\s*)[^\\s,;]+"); + private static final Pattern TEXT_SECRET_PATTERN = Pattern.compile( + "(?i)((?:api[_-]?key|token|secret|password)\\s*[:=]\\s*)[^\\s,;}]+"); private final DebugEmlSuperAgentProperties properties; private final AliyunOssProperties ossProperties; @@ -162,7 +181,9 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe "OSS 上传失败。", exception); } catch (SuperAgentOpenApiException exception) { - markFailed(runId, superAgentFailureSummary(exception), DebugEmlSuperAgentRunStatus.SUPERAGENT_FAILED, nowUtc()); + String safeSummary = superAgentFailureSummary(exception); + logSuperAgentOpenApiFailure(runId, safeSummary, exception); + markFailed(runId, safeSummary, DebugEmlSuperAgentRunStatus.SUPERAGENT_FAILED, nowUtc()); throw new DebugEmlSuperAgentException( HttpStatus.BAD_GATEWAY, "SUPERAGENT_OPEN_API_FAILED", @@ -233,7 +254,9 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe streamSink.error(runId, "OSS_UPLOAD_FAILED", "OSS 上传失败。", DebugEmlSuperAgentRunStatus.FAILED.name()); } catch (SuperAgentOpenApiException exception) { if (runId != null) { - markFailed(runId, superAgentFailureSummary(exception), DebugEmlSuperAgentRunStatus.SUPERAGENT_FAILED, nowUtc()); + String safeSummary = superAgentFailureSummary(exception); + logSuperAgentOpenApiFailure(runId, safeSummary, exception); + markFailed(runId, safeSummary, DebugEmlSuperAgentRunStatus.SUPERAGENT_FAILED, nowUtc()); } streamSink.error( runId, @@ -291,6 +314,52 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe return "SuperAgent Open API 调用失败。"; } + /** + * 记录 SuperAgent Debug 调用失败的安全诊断日志。只写异常类型和脱敏消息,不记录 API Key、Cookie、邮件正文或响应体。 + */ + private void logSuperAgentOpenApiFailure( + Long runId, + String safeSummary, + SuperAgentOpenApiException exception) { + log.warn( + "Debug EML SuperAgent Open API failed. debug_run_id={}, safe_error_summary={}, exception_chain={}", + runId, + safeSummary, + safeExceptionChain(exception)); + } + + /** + * 生成异常链摘要,帮助区分 timeout、connection reset、EOF 等网络/流读取问题。 + */ + private String safeExceptionChain(Throwable throwable) { + List chain = new ArrayList<>(); + Throwable current = throwable; + int depth = 0; + while (current != null && depth < 8) { + String message = trimToNull(current.getMessage()); + String item = current.getClass().getSimpleName(); + if (message != null) { + item += ": " + sanitizeLogMessage(message); + } + chain.add(item); + current = current.getCause(); + depth++; + } + return String.join(" <- ", chain); + } + + /** + * 日志消息脱敏并截断,避免外部异常把 Secret 或过长内容写入日志。 + */ + private String sanitizeLogMessage(String message) { + String sanitized = JSON_SECRET_PATTERN.matcher(message).replaceAll("$1[REDACTED]$2"); + sanitized = AUTHORIZATION_BEARER_SECRET_PATTERN.matcher(sanitized).replaceAll("$1[REDACTED]"); + sanitized = AUTHORIZATION_SECRET_PATTERN.matcher(sanitized).replaceAll("$1[REDACTED]"); + sanitized = COOKIE_SECRET_PATTERN.matcher(sanitized).replaceAll("$1[REDACTED]"); + sanitized = TEXT_SECRET_PATTERN.matcher(sanitized).replaceAll("$1[REDACTED]"); + return safeErrorSummary(sanitized); + } + /** * 取最底层异常,便于判断 HTTP、网络和解析失败类型。 */ @@ -448,9 +517,11 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe streamSink.trace(traceEvent); } }; - SuperAgentOpenApiResult superAgentResult = streamSink == null - ? superAgentOpenApiClient.invokeMailDebug(superAgentRequest) - : superAgentOpenApiClient.invokeMailDebug(superAgentRequest, traceConsumer); + SuperAgentOpenApiResult superAgentResult = invokeSuperAgentWithHeartbeat( + runId, + superAgentRequest, + traceConsumer, + streamSink); List traceEvents = streamedTraceEvents.isEmpty() ? superAgentResult.traceEvents() : List.copyOf(streamedTraceEvents); @@ -503,6 +574,52 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe status.name()); } + /** + * 调用 SuperAgent 期间维持 Debug SSE 心跳,避免无 trace 输出时浏览器或反向代理认为连接空闲。 + */ + private SuperAgentOpenApiResult invokeSuperAgentWithHeartbeat( + Long runId, + SuperAgentMailDebugRequest request, + Consumer traceConsumer, + DebugEmlSuperAgentStreamSink streamSink) { + if (streamSink == null) { + return superAgentOpenApiClient.invokeMailDebug(request); + } + ScheduledExecutorService heartbeatExecutor = null; + ScheduledFuture heartbeatTask = null; + try { + Duration interval = properties.getSseHeartbeatInterval(); + if (interval != null && !interval.isZero() && !interval.isNegative()) { + heartbeatExecutor = Executors.newSingleThreadScheduledExecutor(heartbeatThreadFactory(runId)); + long intervalMillis = Math.max(1L, interval.toMillis()); + heartbeatTask = heartbeatExecutor.scheduleAtFixedRate( + () -> streamSink.heartbeat(runId), + intervalMillis, + intervalMillis, + TimeUnit.MILLISECONDS); + } + return superAgentOpenApiClient.invokeMailDebug(request, traceConsumer); + } finally { + if (heartbeatTask != null) { + heartbeatTask.cancel(false); + } + if (heartbeatExecutor != null) { + heartbeatExecutor.shutdownNow(); + } + } + } + + /** + * 创建 Debug SSE 心跳线程;使用 daemon 线程,避免调试请求异常退出时阻塞 JVM 关闭。 + */ + private ThreadFactory heartbeatThreadFactory(Long runId) { + return runnable -> { + Thread thread = new Thread(runnable, "debug-eml-sse-heartbeat-" + runId); + thread.setDaemon(true); + return thread; + }; + } + /** * SourceMessage 已写入后先回填 Debug run,保证后续 SuperAgent 失败时仍可追踪入库消息和 OSS 原文。 */ @@ -1050,6 +1167,11 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe */ void trace(SuperAgentOpenApiTraceEvent traceEvent); + /** + * 输出安全心跳事件,维持 Debug SSE 长连接。 + */ + void heartbeat(Long runId); + /** * 输出最终 Debug 结果。 */ @@ -1094,6 +1216,14 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe send("superagent_trace", traceEvent); } + @Override + public void heartbeat(Long runId) { + Map data = new LinkedHashMap<>(); + data.put("debug_run_id", runId == null ? null : runId.toString()); + data.put("status", DebugEmlSuperAgentRunStatus.CALLING_SUPERAGENT.name()); + send("debug_heartbeat", data); + } + @Override public void result(DebugEmlSuperAgentRunResult result) { send("superagent_result", result); @@ -1118,7 +1248,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe /** * 写出一个 SSE 事件块并立即 flush。写失败通常表示客户端断开,只关闭流输出,不影响后续业务落库。 */ - private void send(String eventName, Object data) { + private synchronized void send(String eventName, Object data) { if (!active) { return; } diff --git a/server/src/main/resources/application-dev.yml b/server/src/main/resources/application-dev.yml index 5c6f56d..ed7f2a3 100644 --- a/server/src/main/resources/application-dev.yml +++ b/server/src/main/resources/application-dev.yml @@ -59,6 +59,7 @@ debug: enabled: ${DEBUG_EML_UPLOAD_DEV_ENABLED:${DEBUG_EML_UPLOAD_ENABLED:false}} access-key: ${DEBUG_EML_UPLOAD_DEV_ACCESS_KEY:${DEBUG_EML_UPLOAD_ACCESS_KEY:}} max-file-bytes: ${DEBUG_EML_UPLOAD_DEV_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}} + sse-heartbeat-interval: ${DEBUG_EML_UPLOAD_DEV_SSE_HEARTBEAT_INTERVAL:${DEBUG_EML_UPLOAD_SSE_HEARTBEAT_INTERVAL:15s}} auth: bootstrap: diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml index cbcf229..7f35799 100644 --- a/server/src/main/resources/application-prod.yml +++ b/server/src/main/resources/application-prod.yml @@ -57,6 +57,7 @@ debug: enabled: ${DEBUG_EML_UPLOAD_PROD_ENABLED:false} access-key: ${DEBUG_EML_UPLOAD_PROD_ACCESS_KEY:${DEBUG_EML_UPLOAD_ACCESS_KEY}} max-file-bytes: ${DEBUG_EML_UPLOAD_PROD_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}} + sse-heartbeat-interval: ${DEBUG_EML_UPLOAD_PROD_SSE_HEARTBEAT_INTERVAL:${DEBUG_EML_UPLOAD_SSE_HEARTBEAT_INTERVAL:15s}} auth: bootstrap: diff --git a/server/src/main/resources/application-test.yml b/server/src/main/resources/application-test.yml index 205df9f..1779c98 100644 --- a/server/src/main/resources/application-test.yml +++ b/server/src/main/resources/application-test.yml @@ -61,6 +61,7 @@ debug: enabled: ${DEBUG_EML_UPLOAD_TEST_ENABLED:${DEBUG_EML_UPLOAD_ENABLED:false}} access-key: ${DEBUG_EML_UPLOAD_TEST_ACCESS_KEY:${DEBUG_EML_UPLOAD_ACCESS_KEY:}} max-file-bytes: ${DEBUG_EML_UPLOAD_TEST_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}} + sse-heartbeat-interval: ${DEBUG_EML_UPLOAD_TEST_SSE_HEARTBEAT_INTERVAL:${DEBUG_EML_UPLOAD_SSE_HEARTBEAT_INTERVAL:15s}} auth: bootstrap: diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java index 4ebb6aa..1df5003 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java @@ -23,6 +23,7 @@ import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectSto import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult; import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService; import cn.nianxx.thhotel.platform.debug.service.DebugEmlSuperAgentRunService; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.nio.charset.StandardCharsets; @@ -30,10 +31,13 @@ import java.util.List; import java.util.function.Consumer; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; 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.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.http.MediaType; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.mock.web.MockMultipartFile; @@ -48,12 +52,14 @@ import org.springframework.web.client.RestClientResponseException; "debug.eml-upload.enabled=true", "debug.eml-upload.access-key=test-debug-upload-key", "debug.eml-upload.max-file-bytes=1048576", + "debug.eml-upload.sse-heartbeat-interval=25ms", "aliyun.oss.debug-eml-prefix=debug/eml/", "superagent.open-api.enabled=true", "superagent.open-api.external-subject-id=test-debug-eml" }) @AutoConfigureMockMvc @ActiveProfiles("test") +@ExtendWith(OutputCaptureExtension.class) class DebugEmlSuperAgentControllerTest { private static final String ENDPOINT = "/api/system/debug/eml-superagent-runs"; @@ -254,6 +260,50 @@ class DebugEmlSuperAgentControllerTest { .andExpect(content().string(not(containsString("test-debug-upload-key")))); } + @Test + void shouldSendHeartbeatWhileWaitingForSuperAgentStreamResult() 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(), any())).thenAnswer(invocation -> { + Thread.sleep(120); + return new SuperAgentOpenApiResult( + "session-heartbeat-001", + "run-heartbeat-001", + "profile-debug", + "profile-version-debug", + "debug-model", + "{\"ai_task_results\":[{\"task_type\":\"New Booking\"}]}", + 11, + 7, + 18, + List.of("metadata", "values", "end"), + List.of()); + }); + + MvcResult mvcResult = mockMvc.perform(multipart(ENDPOINT + "/stream") + .file(emlFile()) + .param("hotel_id", "HOTEL-TEST") + .param("run_label", "stream-heartbeat") + .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key")) + .andExpect(request().asyncStarted()) + .andReturn(); + + mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)) + .andExpect(content().string(containsString("event: debug_heartbeat"))) + .andExpect(content().string(containsString("\"status\":\"CALLING_SUPERAGENT\""))) + .andExpect(content().string(containsString("event: superagent_result"))) + .andExpect(content().string(containsString("event: done"))) + .andExpect(content().string(not(containsString("test-debug-upload-key")))); + } + @Test void shouldKeepBusinessRunStatusWhenSseClientDisconnects() { when(objectStorageService.putObject(any())).thenAnswer(invocation -> { @@ -313,6 +363,36 @@ class DebugEmlSuperAgentControllerTest { org.assertj.core.api.Assertions.assertThat(debugRunCount).isEqualTo(1L); } + @Test + void shouldLogSafeRootCauseWhenSuperAgentSseReadFails(CapturedOutput output) { + 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(), any())) + .thenThrow(new SuperAgentOpenApiException( + "SuperAgent SSE 读取失败。", + new IOException("simulated stream reset Authorization: Bearer should-not-log"))); + + runService.uploadAndRunStream( + "test-debug-upload-key", + emlFile(), + "HOTEL-TEST", + "stream-read-failed-log", + new ByteArrayOutputStream()); + + org.assertj.core.api.Assertions.assertThat(output.getOut()) + .contains("Debug EML SuperAgent Open API failed") + .contains("SuperAgentOpenApiException: SuperAgent SSE 读取失败。") + .contains("IOException: simulated stream reset") + .doesNotContain("should-not-log") + .doesNotContain("Bearer should-not-log"); + } + @Test void shouldRecordPhaseBeforeUploadingOriginalEmlToOss() throws Exception { AtomicInteger uploadIndex = new AtomicInteger();