增强SuperAgent失败安全诊断日志
This commit is contained in:
@@ -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 文本字段。
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user