diff --git a/dataops-agent-contract/src/main/java/com/synapxnet/goai/contract/AgentContractAutoConfiguration.java b/dataops-agent-contract/src/main/java/com/synapxnet/goai/contract/AgentContractAutoConfiguration.java index f1b2e64..6761d61 100644 --- a/dataops-agent-contract/src/main/java/com/synapxnet/goai/contract/AgentContractAutoConfiguration.java +++ b/dataops-agent-contract/src/main/java/com/synapxnet/goai/contract/AgentContractAutoConfiguration.java @@ -1,5 +1,6 @@ package com.synapxnet.goai.contract; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.web.servlet.FilterRegistrationBean; @@ -29,13 +30,15 @@ DelegatedTokenVerifier delegatedTokenVerifier( * 注册只作用于 Agent v1 API 的上下文过滤器。 * * @param verifier 委托令牌验证器 + * @param objectMapper Spring 统一配置的 JSON 序列化器 * @return Servlet 过滤器注册对象 */ @Bean FilterRegistrationBean agentRequestContextFilter( - DelegatedTokenVerifier verifier) { + DelegatedTokenVerifier verifier, + ObjectMapper objectMapper) { FilterRegistrationBean bean = new FilterRegistrationBean<>(); - bean.setFilter(new AgentRequestContextFilter(verifier)); + bean.setFilter(new AgentRequestContextFilter(verifier, objectMapper)); bean.addUrlPatterns("/api/agent/v1/*"); bean.setOrder(-100); return bean; diff --git a/dataops-agent-contract/src/main/java/com/synapxnet/goai/contract/AgentRequestContextFilter.java b/dataops-agent-contract/src/main/java/com/synapxnet/goai/contract/AgentRequestContextFilter.java index 4ed036c..51db78d 100644 --- a/dataops-agent-contract/src/main/java/com/synapxnet/goai/contract/AgentRequestContextFilter.java +++ b/dataops-agent-contract/src/main/java/com/synapxnet/goai/contract/AgentRequestContextFilter.java @@ -1,13 +1,17 @@ package com.synapxnet.goai.contract; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.MDC; +import org.springframework.http.MediaType; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -18,14 +22,17 @@ final class AgentRequestContextFilter extends OncePerRequestFilter { private static final Pattern TOOL_PATH = Pattern.compile("^/api/agent/v1/tools/([^/]+):invoke$"); private final DelegatedTokenVerifier tokenVerifier; + private final ObjectMapper objectMapper; /** * 创建请求上下文过滤器。 * * @param tokenVerifier 短期委托令牌验证器 + * @param objectMapper Spring 统一配置的 JSON 序列化器 */ - AgentRequestContextFilter(DelegatedTokenVerifier tokenVerifier) { + AgentRequestContextFilter(DelegatedTokenVerifier tokenVerifier, ObjectMapper objectMapper) { this.tokenVerifier = tokenVerifier; + this.objectMapper = objectMapper; } /** @@ -45,23 +52,52 @@ protected void doFilterInternal( return; } try { - String workspaceId = requiredHeader(request, "X-OpenXnet-Workspace-Id"); - String incidentId = requiredHeader(request, "X-OpenXnet-Incident-Id"); - String traceId = requiredHeader(request, "X-OpenXnet-Trace-Id"); - String toolName = requiredHeader(request, "X-OpenXnet-Tool-Name"); - String idempotencyKey = requiredHeader(request, "Idempotency-Key"); - verifyPathTool(request.getRequestURI(), toolName); - String actorId = tokenVerifier.verify(request.getHeader("Authorization"), workspaceId, toolName); - AgentContract.RequestContext context = new AgentContract.RequestContext( - workspaceId, incidentId, traceId, toolName, idempotencyKey, actorId, null); - request.setAttribute(AgentContract.CONTEXT_ATTRIBUTE, context); - putMdc(context); + try { + String workspaceId = requiredHeader(request, "X-OpenXnet-Workspace-Id"); + String incidentId = requiredHeader(request, "X-OpenXnet-Incident-Id"); + String traceId = requiredHeader(request, "X-OpenXnet-Trace-Id"); + String toolName = requiredHeader(request, "X-OpenXnet-Tool-Name"); + String idempotencyKey = requiredHeader(request, "Idempotency-Key"); + verifyPathTool(request.getRequestURI(), toolName); + String actorId = tokenVerifier.verify(request.getHeader("Authorization"), workspaceId, toolName); + AgentContract.RequestContext context = new AgentContract.RequestContext( + workspaceId, incidentId, traceId, toolName, idempotencyKey, actorId, null); + request.setAttribute(AgentContract.CONTEXT_ATTRIBUTE, context); + putMdc(context); + } catch (AgentContractException exception) { + writeContractError(response, exception); + return; + } filterChain.doFilter(request, response); } finally { MDC.clear(); } } + /** + * 将过滤器阶段的鉴权或契约异常写为脱敏公共 JSON 包络。 + * + * @param response HTTP 响应 + * @param exception 已识别的公共契约异常 + * @throws IOException 响应写入失败时抛出 + */ + private void writeContractError( + HttpServletResponse response, + AgentContractException exception) throws IOException { + AgentContract.ToolMeta meta = new AgentContract.ToolMeta( + null, null, null, null, null, AgentContract.CONTRACT_VERSION, + Instant.now(), 0L, "agent-contract", null, null); + AgentContract.ToolError error = new AgentContract.ToolError( + exception.getCode(), exception.getMessage(), exception.isRetryable(), exception.getDetails()); + AgentContract.ToolResponse body = new AgentContract.ToolResponse<>( + false, null, error, meta, null); + response.resetBuffer(); + response.setStatus(exception.getHttpStatus()); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getOutputStream(), body); + } + /** * 读取并限制公共 Header,避免空值和超长日志污染。 * diff --git a/dataops-agent-contract/src/test/java/com/synapxnet/goai/contract/AgentRequestContextFilterTest.java b/dataops-agent-contract/src/test/java/com/synapxnet/goai/contract/AgentRequestContextFilterTest.java new file mode 100644 index 0000000..dd68ee2 --- /dev/null +++ b/dataops-agent-contract/src/test/java/com/synapxnet/goai/contract/AgentRequestContextFilterTest.java @@ -0,0 +1,74 @@ +package com.synapxnet.goai.contract; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 验证 Agent 过滤器在控制器执行前返回稳定且脱敏的公共错误包络。 + */ +class AgentRequestContextFilterTest { + + private static final String SECRET = "goai-filter-test-secret-1234567890"; + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + + /** + * 验证缺少公共请求头时直接返回 400 JSON,而不是由容器转换为 500。 + * + * @throws Exception 测试请求执行或 JSON 解析失败时抛出 + */ + @Test + void returnsStructuredBadRequestWhenHeadersAreMissing() throws Exception { + AgentRequestContextFilter filter = createFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/agent/v1/actions/action-1"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain()); + + JsonNode body = objectMapper.readTree(response.getContentAsByteArray()); + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getContentType()).startsWith("application/json"); + assertThat(body.path("success").asBoolean()).isFalse(); + assertThat(body.path("error").path("code").asText()).isEqualTo("INVALID_ARGUMENT"); + assertThat(body.path("meta").path("workspaceId").isNull()).isTrue(); + } + + /** + * 验证公共请求头有效但未携带委托令牌时返回 401 脱敏包络。 + * + * @throws Exception 测试请求执行或 JSON 解析失败时抛出 + */ + @Test + void returnsStructuredUnauthorizedWhenTokenIsMissing() throws Exception { + AgentRequestContextFilter filter = createFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/agent/v1/actions/action-1"); + request.addHeader("X-OpenXnet-Workspace-Id", "ws_goai_demo"); + request.addHeader("X-OpenXnet-Incident-Id", "incident-1"); + request.addHeader("X-OpenXnet-Trace-Id", "trace-1"); + request.addHeader("X-OpenXnet-Tool-Name", "dataops.workflow.instance.get"); + request.addHeader("Idempotency-Key", "idem-1"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain()); + + JsonNode body = objectMapper.readTree(response.getContentAsByteArray()); + assertThat(response.getStatus()).isEqualTo(401); + assertThat(body.path("error").path("code").asText()).isEqualTo("UNAUTHENTICATED"); + assertThat(response.getContentAsString()).doesNotContain(SECRET); + } + + /** + * 创建使用测试密钥和标准受众的过滤器实例。 + * + * @return 可独立执行的 Agent 请求过滤器 + */ + private AgentRequestContextFilter createFilter() { + DelegatedTokenVerifier verifier = new DelegatedTokenVerifier(SECRET, "openxnet-agent-adapter"); + return new AgentRequestContextFilter(verifier, objectMapper); + } +}