一、背景
最近做 CRM 对外 WebApi 与客服热线平台(400 呼叫中心)的接口对接:平台方要调我们的六个查询接口(部门、员工、经销商、联系人、客户、序列号)。上线联调当天,对面怎么调都是 415 Unsupported Media Type,日志里一片 ProblemDetails。
这不是第一次了——之前两个建单接口也栽过同一个坑,当时是打补丁式修的。这次决定根治。
对端平台的特征,也是这次问题的根源:
请求体是JSON 信封:
{"action":"queryDevice","data":{...}},真正的业务参数在data节点里;Content-Type 不保证是 application/json,经常是空的或别的值;
对端有自己的发布节奏,不会为了我们改调用格式——只能我们适配。
二、415 的根因:内容协商发生在 Action 之前
原来的接口是这么写的(反面教材):
[HttpPost("device-query")] [Consumes("application/json")] public IActionResult DeviceQuery([FromBody] QueryRequest request) { ... }两个特性叠加,必然出事:
[Consumes]声明"我只接受 application/json"。ASP.NET Core 按 Content-Type 选择输入格式化器(InputFormatter),这一步发生在进入 Action 之前。Content-Type 不是 application/json 时,框架直接拒绝返回 415,Action 一行代码都不会执行;[FromBody]依赖模型绑定,同样按 Content-Type 选 formatter。就算恰好带对了 Content-Type,body 是信封结构,和 DTO 对不上,绑定出来的参数要么是空对象要么直接报错。
排查时日志里的两个信号,看到就能秒定位:
415 ProblemDetails+ traceId:内容协商在中间件层就拒了,请求体根本进不了业务日志;请求参数显示
[Body too large]:ContentLength 为 null 时,日志中间件采集不到 body,这是它显示的占位文案。
三、根治方案:绕过内容协商,手工解析
思路:Action 不声明任何参数、不加 Consumes,把 HttpContext 拿进来自己拆包。
[HttpGet("device-query")] [HttpPost("device-query")] public Task<IActionResult> DeviceQuery() { return RunCallCenter400Async(async () => { var request = await CallCenterRequestParser.ReadDataAsync<QueryRequest>(HttpContext.Request); var data = _queryService.Query(request); return OutContractJson(1, "查询成功", data); }); }注意GET + POST 双收:对端用哪种 HTTP 方法不稳定,两个都挂上最省心。
解析器从三个来源依次找 data,优先级如下:
public static async Task<T> ReadDataAsync<T>(HttpRequest request) { string raw; // ① 表单渠道优先:老接口历史上收 multipart/form 的 data 字段 if (request.HasFormContentType) { raw = (await request.ReadFormAsync())["data"]; } else { // ② 读原始 body 原文:不管 Content-Type 是什么都读 request.EnableBuffering(); using var reader = new StreamReader(request.Body); raw = await reader.ReadToEndAsync(); request.Body.Position = 0; } // ③ 查询串兜底:?data=... if (string.IsNullOrWhiteSpace(raw)) raw = request.Query["data"]; // 信封拆解:有 data 节点取 data(字符串形态再解一层);没有 data 节点,根对象即业务参数 var doc = JsonDocument.Parse(raw); var root = doc.RootElement; var payload = root.TryGetProperty("data", out var dataProp) ? ParseDataNode(dataProp) : root.Clone(); // 反序列化 + DTO 数据注解校验 var dto = payload.Deserialize<T>(jsonOptions); // JsonSerializerDefaults.Web:camelCase 不敏感 Validator.ValidateObject(dto, new ValidationContext(dto), validateAllProperties: true); return dto; }几个要点:
data 节点三种形态都要兼容:对端实现不稳定——有时 data 是对象,有时是 JSON 字符串(需要再解一层),有时干脆没有 data、根对象就是业务参数。三个分支都要覆盖;
反序列化用
JsonSerializerDefaults.Web:camelCase 不敏感、数字字符串兼容,能吃掉对端大部分大小写和类型不齐的问题;保留 DTO 数据注解校验:绕过了框架的模型绑定,但
Validator.ValidateObject要自己调用,别把参数合法性也绕没了;EnableBuffering()+Body.Position = 0:想在一个请求里先读 body 再干别的(比如日志中间件还要采集 body),必须让流可重读。
四、异常收口:永远回 200,把结果放业务码
对端平台不认 HTTP 状态码,只认响应体里的业务码(它的契约是 code=1 成功 / code=0 失败)。所以哪怕解析失败,也要回 HTTP 200,把失败原因写进业务码:
private async Task<IActionResult> RunCallCenter400Async(Func<Task<IActionResult>> action) { try { return await action(); } catch (InvalidOperationException ex) // 对端发错了:解析/校验失败 → 记 Warning { _logger.LogWarning(ex, "请求格式校验失败"); return OutContractJson(0, ex.Message, null); } catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested) { throw; } catch (Exception ex) // 我们错了 → 记 Error { _logger.LogError(ex, "接口处理失败"); return OutContractJson(0, ex.Message, null); } }这里的设计点是:用异常类型当"信号通道"。解析器只负责抛InvalidOperationException,收口方法按异常类型分诊——它代表"对端发错了",记 Warning;其他异常代表"我们错了",记 Error。比到处 try-catch 干净,排查日志时一眼分清责任方。
五、验证:穷举请求形态
修复后本地把请求形态穷举了一遍:GET/POST × {JSON 信封、表单、平铺、查询串、空信封、非法 JSON},全部返回 200 + 业务码,不再出现 415/405。上线后对端直接可用。
六、总结
[Consumes]是"声明我能吃什么",对端给什么它说了不算——对端不可控时,这层声明就是在埋雷;绕过内容协商不是放弃安全:DTO 校验、鉴权、限流一层都不能少,只是把"拆包裹"的主动权拿回来;
接口联调永远以对端实测为准,文档只能当参考——这是血泪教训。