一次接口请求 HTTP 400 响应码报错排查记录

起因

开发权限代理的功能,基于 session 维护了权限代理后用户信息,存储用户信息的userId。后边上测试环境,检查用户代理之后的数据权限,发现数据和代理后用户所拥有的角色的数据权限对不上。

仔细排查接口,发现系统之前一些接口设计是直接传递 userId 查询数据,没有基于当前用户登录状态信息 userId 来查询,会出现数据安全问题。

为了解决这个问题,统一处理接口参数替换,专门写了一个过滤器,如果接口请求方式为 GET 或者 POST,并且请求参数中存在 userId 参数查询数据的,经过过滤器会替换成当前服务器上 session 维护的用户信息(包括两种情况:代理过和没代理过的),接着用包裹后的 ServletHttpRequest 对象进行后续的请求。具体代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@Slf4j  
@Component  
@Order  
public class PermissionProxyAuthorizerIdFilter implements Filter {  
  
    public static final String USER_ID = "userId";  
    private final ObjectMapper objectMapper = new ObjectMapper();  
  
    @Override  
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)  
            throws IOException, ServletException {  
  
        HttpServletRequest httpRequest = (HttpServletRequest) request;  
        RepeatableReadHttpServletRequestWrapper requestWrapper = new RepeatableReadHttpServletRequestWrapper(httpRequest);  
  
        // 替换 URL 参数  
        replaceUrlParameter(requestWrapper);  
  
        // 替换 RequestBody        
        replaceRequestBody(requestWrapper);  
  
        // 传递包装后的请求  
        chain.doFilter(requestWrapper, response);  
    }  
  
    private void replaceUrlParameter(RepeatableReadHttpServletRequestWrapper requestWrapper) {  
        String userId = requestWrapper.getParameter(USER_ID);  
        if (StringUtils.hasText(userId)) {  
            Long authorizerId = (Long) requestWrapper.getSession().getAttribute(PermissionProxyService.AUTHORIZER_ID);  
            if (authorizerId != null) {  
                requestWrapper.setParameter(USER_ID, String.valueOf(authorizerId));  
                log.info("URL参数 userId 替换: {} -> {}", userId, authorizerId);  
            }  
        }  
    }  
  
    private void replaceRequestBody(RepeatableReadHttpServletRequestWrapper requestWrapper) {  
        String contentType = requestWrapper.getContentType();  
  
        if (contentType != null && contentType.contains("application/json")) {  
            String body = requestWrapper.getBody();  
  
            if (StringUtils.hasText(body)) {  
                try {  
                    JsonNode jsonNode = objectMapper.readTree(body);  
                    if (jsonNode.has(USER_ID)) {  
                        String userId = jsonNode.get(USER_ID).asText();  
  
                        Long authorizerId = (Long) requestWrapper.getSession().getAttribute(PermissionProxyService.AUTHORIZER_ID);  
                        if (authorizerId != null) {  
                            ((ObjectNode) jsonNode).put(USER_ID, authorizerId);  
                            log.info("RequestBody userId 替换: {} -> {}", userId, authorizerId);  
                        }  
                        String newBody = objectMapper.writeValueAsString(jsonNode);  
                        requestWrapper.setBody(newBody);  
                    }  
                } catch (JsonProcessingException e) {  
                    log.info("解析或修改 RequestBody 失败", e);  
                }  
            }  
  
        }  
    }  
  
}

功能上测试环境之后,测试权限代理功能,切换用户的权限之后,请求某些数据查询的接口会有接口 400 响应码错误,后边发现这些请求大多数请求方式是 POST 和 PUT,最明确的一个接口报错是地图上传接口报错,响应如下:

1
2
3
4
5
6
7
{
	"timestamp": "2025-11-19T04:44:58.142+00:00",
	"status": 400,
	"error": "Bad Request",
	"message": "",
	"path": "/map/uploadFile"
}

排查经过

HTTP 400响应码排查

咨询了一下 Deepseek,给出如下的排查思路。

HTTP 400响应码主要是客户端请求报文有误,包括:请求方法,URL格式,请求头和请求体等。 使用开发工具验证请求参数,关注 Content-Type 和 JSON语法以及协议一致性问题。

排查思路如下:

  1. 使用工具辅助排查。通过Postman或curl工具手动发送请求,排除前端代码干扰;
  2. 使用tcpdump或Wireshark抓包分析,查看实际发送的HTTP请求内容;
  3. 启用Spring Boot的调试日志级别,获取更详细的请求解析过程信息。

实际排查过程:先使用 Postman 测试接口,完整请求参数附上,还是会报 400 响应码。

使用控制变量法,我先注释掉过滤器的代码,重新上测试部署,发现文件上传接口一切正常,看来是过滤器的代码出现问题,但是从实际上来看没看出哪个地方有问题。

过滤器代码排查

将过滤器代码扔给 Deepseek,给出如下的解释:

当请求经过过滤器时,如果过滤器读取了请求体(即使只是读取参数),就会导致:

  • multipart/form-data请求的边界信息被破坏
  • Spring的MultipartResolver无法正确解析文件部分
  • @RequestParam("file")找不到对应的文件数据

后续经过仔细排查和查找日志之后,发现了一条 WARN 级别日志:

1
-- WARN -- [http-nio-8081-exec-10] -- o.s.w.s.m.support.DefaultHandlerExceptionResolver:208 --- Resolved [org.springframework.web.multipart.support.MissingServletRequestPartException: Required request part 'file' is not present]

理解文件上传报错和包装器影响

RepeatableReadHttpServletRequestWrapper的工作原理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class RepeatableReadHttpServletRequestWrapper extends HttpServletRequestWrapper {
    private final ByteArrayOutputStream cachedContent;
    
    public ContentCachingRequestWrapper(HttpServletRequest request) {
        super(request);
        // 在构造函数中就可能开始缓存内容
        this.cachedContent = new ByteArrayOutputStream();
        // 可能会调用getInputStream()或getReader()来读取内容
    }
    
    @Override
    public ServletInputStream getInputStream() throws IOException {
        // 读取并缓存输入流,破坏原始multipart数据
        // ... 实现细节
    }
}

破坏过程:

  1. 包装器创建时调用getInputStream()
  2. 读取整个请求体到内存缓存
  3. multipart边界标记被当作普通文本处理
  4. Spring后续无法识别文件部分的开始和结束

解决方案

确保过滤器中没有任何参数读取操作影响文件上传

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Component
public class YourFilter implements Filter {
	
	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		HttpServletRequest httpRequest = (HttpServletRequest) request;
		
		if (isMultipartRequest(httpRequest)) {
			// 对于multipart请求,绝对不要调用以下方法:
			// httpRequest.getParameter() 
			// httpRequest.getParameterMap()
			// httpRequest.getParameterNames()
			// httpRequest.getParameterValues()
			// httpRequest.getReader()
			// httpRequest.getInputStream()
			
			chain.doFilter(request, response);
			return;
		}
		
		// 只有普通请求才进行参数处理
		String token = httpRequest.getParameter("token"); // 这只对普通请求有效
		// ... 其他过滤逻辑
		
		chain.doFilter(request, response);
	}
	
	private boolean isMultipartRequest(HttpServletRequest request) {
		String contentType = request.getContentType();
		return contentType != null && contentType.startsWith("multipart/");
	}
}

可以看到之前过滤器中,无论是否替换 userId 都是使用包装器进行后续的请求,这会导致multipart/form-data请求的边界信息被破坏,可以延迟包装器创建,如果替换成功创建包装器并且使其进入后续请求中,如果替换失败,保持原来的请求体,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@Slf4j  
@Component  
@Order  
public class PermissionProxyAuthorizerIdFilter implements Filter {  
  
    public static final String USER_ID = "userId";  
    private final ObjectMapper objectMapper = new ObjectMapper();  
  
    @Resource  
    private AuthorizerIdFilterWhitelistMatcher authorizerIdFilterWhitelistMatcher;  
  
    @Override  
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)  
            throws IOException, ServletException {  
  
        HttpServletRequest httpRequest = (HttpServletRequest) request;  
        String requestURI = httpRequest.getRequestURI();  
  
        if (authorizerIdFilterWhitelistMatcher.isWhitelisted(requestURI)) {  
            // 白名单路径,直接放行  
            chain.doFilter(request, response);  
            return;        }  
  
        RepeatableReadHttpServletRequestWrapper requestWrapper = replaceUserId(httpRequest);  
  
        if (requestWrapper != null) {  
            chain.doFilter(requestWrapper, response);  
        } else {  
            chain.doFilter(request, response);  
        }  
    }  
  
    private RepeatableReadHttpServletRequestWrapper replaceUserId(HttpServletRequest httpRequest) throws IOException {  
        Long authorizerId = (Long) httpRequest.getSession().getAttribute(PermissionProxyService.AUTHORIZER_ID);  
        if (authorizerId == null) {  
            return null;  // 没有权限代理,不用替换  
        }  
        String userId = httpRequest.getParameter(USER_ID);  
        if (StringUtils.hasText(userId)) {  
            RepeatableReadHttpServletRequestWrapper requestWrapper = new RepeatableReadHttpServletRequestWrapper(httpRequest);  
            requestWrapper.setParameter(USER_ID, String.valueOf(authorizerId));  
            log.info("URL参数 userId 替换: {} -> {}", userId, authorizerId);  
            return requestWrapper;  
        } else {  
            String contentType = httpRequest.getContentType();  
  
            if (contentType != null && contentType.contains("application/json")) {  
                RepeatableReadHttpServletRequestWrapper requestWrapper = new RepeatableReadHttpServletRequestWrapper(httpRequest);  
                String body = requestWrapper.getBody();  
  
                if (StringUtils.hasText(body)) {  
                    try {  
                        JsonNode jsonNode = objectMapper.readTree(body);  
                        if (jsonNode.has(USER_ID)) {  
                            userId = jsonNode.get(USER_ID).asText();  
                            if (jsonNode.has(USER_ID)) {  
                                userId = jsonNode.get(USER_ID).asText();  
                                ((ObjectNode) jsonNode).put(USER_ID, authorizerId);  
                                log.info("RequestBody userId 替换: {} -> {}", userId, authorizerId);  
                                String newBody = objectMapper.writeValueAsString(jsonNode);  
                                requestWrapper.setBody(newBody);  
                            }  
                        }  
                    } catch (Exception e) {  
                        log.error("解析或修改 RequestBody 失败", e);  
                    }  
                }  
                return requestWrapper;  
            }  
        }  
        return null;  
    }  
}

此外,添加白名单过滤类 AuthorizerIdFilterWhitelistMatcher

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data  
@Component  
@ConfigurationProperties("permissionProxy.whitelist")  
public class AuthorizerIdFilterWhitelistMatcher {  
  
    private final AntPathMatcher pathMatcher = new AntPathMatcher();  
  
    // 定义白名单路径列表  
    private List<String> paths;  
  
    /**  
     * 检查请求路径是否在白名单中  
     * @param requestURI 请求路径  
     * @return 是否为白名单路径  
     */  
    public boolean isWhitelisted(String requestURI) {  
        boolean flag = paths.stream()  
                .anyMatch(pattern -> pathMatcher.match(pattern, requestURI));  
        return flag;  
    }  
  
}

总结

此次接口响应码 400 错误主要是对multipart请求使用请求体读取操作导致的,排查后修改逻辑也是先判断请求类型,再决定是否读取请求体以及使用包装器进行后续请求。总的来说,注意以下几点:

  1. ✅ 先检查请求类型,再决定是否包装请求体
  2. ✅ multipart请求使用原始request请求体
  3. ✅ 非multipart请求才使用包装器
  4. ❌ 不要在检查前创建任何包装器
  5. ❌ 不要对multipart请求使用任何请求体读取操作

附录

参考文献

版权信息

本文原载于kitebin.top,遵循CC BY-NC-SA 4.0协议,复制请保留原文出处。

Built with Hugo
主题 StackJimmy 设计