增强Debug EML SSE诊断与心跳

This commit is contained in:
andy
2026-07-12 17:43:20 +08:00
parent 5d82b29c53
commit 937aa569ff
10 changed files with 237 additions and 6 deletions

View File

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

View File

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

View File

@@ -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 默认关闭。 |

View File

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

View File

@@ -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;
}
}

View File

@@ -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<String> 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<SuperAgentOpenApiTraceEvent> 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<SuperAgentOpenApiTraceEvent> 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<String, Object> 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;
}

View File

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

View File

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

View File

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

View File

@@ -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();