增强SuperAgent失败安全诊断日志

This commit is contained in:
andy
2026-07-22 14:44:21 +07:00
parent 2fb518be09
commit 71191a10b0
3 changed files with 373 additions and 32 deletions

View File

@@ -20,6 +20,9 @@ import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -29,10 +32,19 @@ import org.springframework.stereotype.Service;
@Service
public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient {
private static final Logger log = LoggerFactory.getLogger(SuperAgentOpenApiClientImpl.class);
private static final String CSRF_HEADER_NAME = "X-CSRF-Token";
private static final String CSRF_COOKIE_NAME = "csrf_token";
private static final int CSRF_TOKEN_BYTES = 32;
private static final int SAFE_LOG_VALUE_MAX_LENGTH = 512;
private static final SecureRandom CSRF_RANDOM = new SecureRandom();
private static final Pattern JSON_SECRET_PATTERN = Pattern.compile(
"(?i)(\"(?:api[_-]?key|token|secret|password|cookie|authorization|csrf)\"\\s*:\\s*\")[^\"]*(\")");
private static final Pattern HEADER_SECRET_PATTERN = Pattern.compile(
"(?i)((?:authorization|cookie|x-csrf-token|csrf[_-]?token)\\s*:\\s*)[^\\s,;]+");
private static final Pattern TEXT_SECRET_PATTERN = Pattern.compile(
"(?i)((?:api[_-]?key|token|secret|password|cookie|authorization|csrf[_-]?token)\\s*[=:]\\s*)[^\\s,;}]+");
private static final Pattern URL_PATTERN = Pattern.compile("(?i)\\b(?:https?|oss)://[^\\s,;\"'<>]+");
private final SuperAgentOpenApiProperties properties;
private final SuperAgentOpenApiSseParser sseParser;
@@ -104,29 +116,38 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient {
*/
private String createSession(HttpClient httpClient, URI baseUri, SuperAgentOpenApiMessageRequest request)
throws Exception {
Map<String, Object> body = new LinkedHashMap<>();
body.put("external_subject_id", textOrDefault(request.externalSubjectId(), properties.getExternalSubjectId()));
body.put("idempotency_key", request.idempotencyKey() + "-session");
body.put("metadata", request.metadata());
HttpRequest httpRequest = baseRequest(baseUri.resolve("/api/open/agent-sessions"), request.requestId())
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body), StandardCharsets.UTF_8))
.build();
HttpResponse<String> httpResponse = httpClient.send(
httpRequest,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
require2xx(httpResponse.statusCode(), httpResponse.body());
String response = httpResponse.body();
JsonNode json = objectMapper.readTree(response == null ? "{}" : response);
String sessionId = text(json, "session_id");
if (sessionId == null) {
sessionId = text(json, "id");
try {
Map<String, Object> body = new LinkedHashMap<>();
body.put("external_subject_id", textOrDefault(request.externalSubjectId(), properties.getExternalSubjectId()));
body.put("idempotency_key", request.idempotencyKey() + "-session");
body.put("metadata", request.metadata());
HttpRequest httpRequest = baseRequest(baseUri.resolve("/api/open/agent-sessions"), request.requestId())
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body), StandardCharsets.UTF_8))
.build();
HttpResponse<String> httpResponse = httpClient.send(
httpRequest,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
require2xx(httpResponse.statusCode(), httpResponse.body());
String response = httpResponse.body();
JsonNode json = objectMapper.readTree(response == null ? "{}" : response);
String sessionId = text(json, "session_id");
if (sessionId == null) {
sessionId = text(json, "id");
}
if (sessionId == null) {
throw new SuperAgentOpenApiException("SuperAgent 创建 session 响应缺少 session_id。");
}
return sessionId;
} catch (SuperAgentOpenApiException exception) {
logOpenApiFailure("create_session", request, null, null, exception);
throw exception;
} catch (Exception exception) {
restoreInterruptedFlag(exception);
logOpenApiFailure("create_session", request, null, null, exception);
throw exception;
}
if (sessionId == null) {
throw new SuperAgentOpenApiException("SuperAgent 创建 session 响应缺少 session_id。");
}
return sessionId;
}
/**
@@ -148,22 +169,47 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient {
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body), StandardCharsets.UTF_8))
.build();
HttpResponse<InputStream> httpResponse = httpClient.send(
httpRequest,
HttpResponse.BodyHandlers.ofInputStream());
require2xxOrClose(httpResponse.statusCode(), httpResponse.body());
SuperAgentOpenApiSseParser.ParsedState state = sseParser.newState(sessionId);
HttpResponse<InputStream> httpResponse;
try {
httpResponse = httpClient.send(
httpRequest,
HttpResponse.BodyHandlers.ofInputStream());
require2xxOrClose(httpResponse.statusCode(), httpResponse.body());
} catch (SuperAgentOpenApiException exception) {
logOpenApiFailure("messages_stream", request, sessionId, state, exception);
throw exception;
} catch (Exception exception) {
restoreInterruptedFlag(exception);
logOpenApiFailure("messages_stream", request, sessionId, state, exception);
throw exception;
}
applyRunLocation(baseUri, httpResponse, state);
try {
consumeResponse(httpResponse.body(), state, traceConsumer);
} catch (SuperAgentOpenApiException exception) {
if (!state.endSeen() && state.runUri() != null) {
recover(httpClient, request.requestId(), state, traceConsumer);
try {
recover(httpClient, request.requestId(), state, traceConsumer);
} catch (SuperAgentOpenApiException recoveryException) {
logOpenApiFailure("sse_recover", request, sessionId, state, recoveryException);
throw recoveryException;
} catch (Exception recoveryException) {
restoreInterruptedFlag(recoveryException);
logOpenApiFailure("sse_recover", request, sessionId, state, recoveryException);
throw recoveryException;
}
} else {
logOpenApiFailure("sse_consume", request, sessionId, state, exception);
throw exception;
}
}
return sseParser.requireSuccessfulResult(state);
try {
return sseParser.requireSuccessfulResult(state);
} catch (SuperAgentOpenApiException exception) {
logOpenApiFailure("sse_result", request, sessionId, state, exception);
throw exception;
}
}
/**
@@ -231,10 +277,7 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient {
*/
private void require2xx(int statusCode, String responseBody) {
if (statusCode < 200 || statusCode >= 300) {
String safeBody = responseBody == null ? "" : responseBody;
if (safeBody.length() > 512) {
safeBody = safeBody.substring(0, 512);
}
String safeBody = responseBody == null ? "" : safeLogText(responseBody);
throw new SuperAgentOpenApiException("SuperAgent Open API HTTP 调用失败status=" + statusCode + "body=" + safeBody);
}
}
@@ -363,6 +406,131 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient {
consumeResponse(response.body(), state, traceConsumer);
}
/**
* HTTP 调用被中断时恢复中断标记,避免上层线程池误判当前线程仍可继续执行。
*/
private void restoreInterruptedFlag(Exception exception) {
if (exception instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
}
/**
* 记录 SuperAgent 失败边界诊断。只打印可定位的安全摘要,不打印邮件正文、附件 URL、鉴权头或 Secret。
*/
private void logOpenApiFailure(
String phase,
SuperAgentOpenApiMessageRequest request,
String sessionId,
SuperAgentOpenApiSseParser.ParsedState state,
Exception exception) {
if (!log.isWarnEnabled()) {
return;
}
log.warn(
"SuperAgent Open API failed. phase={}, request_id={}, external_subject_id={}, "
+ "idempotency_key={}, message_chars={}, session_id={}, run_id={}, run_uri_path={}, "
+ "last_event_id={}, event_types={}, failure_code={}, failure_message={}, metadata={}, "
+ "safe_error_summary={}, exception_chain={}",
safeLogText(phase),
safeLogText(request.requestId()),
safeLogText(textOrDefault(request.externalSubjectId(), properties.getExternalSubjectId())),
safeLogText(request.idempotencyKey()),
request.message() == null ? 0 : request.message().length(),
safeLogText(sessionId),
state == null ? null : safeLogText(state.runId()),
state == null ? null : safeUriPath(state.runUri()),
state == null ? null : safeLogText(state.lastEventId()),
state == null ? null : state.eventTypes(),
state == null ? null : safeLogText(state.failureCode()),
state == null ? null : safeLogText(state.failureMessage()),
safeMetadataSummary(request.metadata()),
safeLogText(exception.getMessage()),
safeExceptionChain(exception));
}
/**
* 输出可排查的 metadata 摘要,只保留内部 ID / hotel 等定位字段。
*/
private Map<String, String> safeMetadataSummary(Map<String, Object> metadata) {
Map<String, String> summary = new LinkedHashMap<>();
if (metadata == null || metadata.isEmpty()) {
return summary;
}
putSafeMetadata(summary, metadata, "debug_run_id");
putSafeMetadata(summary, metadata, "dispatch_run_id");
putSafeMetadata(summary, metadata, "source_message_id");
putSafeMetadata(summary, metadata, "hotel_id");
putSafeMetadata(summary, metadata, "external_message_id");
putSafeMetadata(summary, metadata, "provider");
putSafeMetadata(summary, metadata, "schema_version");
return summary;
}
/**
* 追加单个安全 metadata 字段。
*/
private void putSafeMetadata(Map<String, String> summary, Map<String, Object> metadata, String fieldName) {
Object value = metadata.get(fieldName);
if (value == null) {
return;
}
summary.put(fieldName, safeLogText(String.valueOf(value)));
}
/**
* 生成异常链短摘要,避免日志输出完整堆栈和潜在敏感内容。
*/
private String safeExceptionChain(Throwable throwable) {
StringBuilder builder = new StringBuilder();
Throwable current = throwable;
int depth = 0;
while (current != null && depth < 4) {
if (!builder.isEmpty()) {
builder.append(" <- ");
}
builder.append(current.getClass().getSimpleName());
String message = safeLogText(current.getMessage());
if (message != null && !message.isBlank()) {
builder.append(": ").append(message);
}
current = current.getCause();
depth++;
}
return builder.toString();
}
/**
* 输出 URI 的路径部分,避免在日志里写入完整域名、签名参数或外链。
*/
private String safeUriPath(String uri) {
if (blank(uri)) {
return null;
}
try {
URI parsedUri = URI.create(uri);
return safeLogText(parsedUri.getPath());
} catch (Exception exception) {
return safeLogText(uri);
}
}
/**
* 对日志文本做 Secret / URL 脱敏和长度截断。
*/
private String safeLogText(String value) {
if (value == null) {
return null;
}
String sanitized = JSON_SECRET_PATTERN.matcher(value).replaceAll("$1***$2");
sanitized = HEADER_SECRET_PATTERN.matcher(sanitized).replaceAll("$1***");
sanitized = TEXT_SECRET_PATTERN.matcher(sanitized).replaceAll("$1***");
sanitized = URL_PATTERN.matcher(sanitized).replaceAll("***URL***");
return sanitized.length() > SAFE_LOG_VALUE_MAX_LENGTH
? sanitized.substring(0, SAFE_LOG_VALUE_MAX_LENGTH)
: sanitized;
}
/**
* 读取 JSON 文本字段。
*/

View File

@@ -589,5 +589,26 @@ public class SuperAgentOpenApiSseParser {
public String lastEventId() {
return lastEventId;
}
/**
* 返回已消费的 SSE 事件类型,用于失败诊断。
*/
public List<String> eventTypes() {
return List.copyOf(eventTypes);
}
/**
* 返回 SSE 失败事件中的错误码,用于失败诊断。
*/
public String failureCode() {
return failureCode;
}
/**
* 返回 SSE 失败事件中的错误摘要,用于失败诊断。
*/
public String failureMessage() {
return failureMessage;
}
}
}