
做苍穹外卖项目做到 day11 的销量TOP10商品展示时我踩了一个很典型的坑页面上的柱状图每个商品名都在X轴标签清清楚楚但柱子高度全是 0数字也一个都不显示。第一反应以为是 ECharts 配置写错了翻来覆去调 series、调 tooltip刷新好几遍都没用最后一路排查到后端才发现问题根本不在前端渲染而是接口返回的数据里销量字段本来就是空的。这篇文章把完整的排查过程和修复方案写出来给正在做或者准备做这个功能的同学一个参考尤其是那些遇到“前端有名字、数据却没有值”这类前后端字段对不上问题的人应该能帮你省下不少时间。1. 先把功能拆清楚销量TOP10到底在统计什么1.1 day11阶段出现这个功能的业务背景做到 day11苍穹外卖项目的管理端基本功能已经趋于完整这时候开始做数据统计和经营报表。销量TOP10商品展示就是其中一个很常见的模块把一段时间内卖出份数最多的前十个商品列出来用柱状图或排行榜的形式展示帮助运营快速判断哪些菜品是爆款、哪些菜品可能需要调整。这个功能放在管理端面向的是店长或者运营人员不是普通用户。所以它的展示风格偏报表化需要的是“一眼看懂”的排名信息。我当时的页面结构是顶部放一个时间筛选器中间一个柱状图X轴显示商品名称Y轴显示销量鼠标悬浮时显示具体数值。功能本身不算复杂但它的数据来源横跨订单表、订单明细表和商品表三张核心表涉及的聚合查询、状态过滤、分组排序都是后续很多统计功能的基础。day11这个阶段之所以单独把它拎出来做本质上是想让你把“多表关联 分组聚合 前端图表联动”这条完整链路走通。1.2 统计口径选错后面全白做销量这个词在实际业务里有好几种理解方式我当时先确认了一个核心问题这里的销量到底是“卖了多少份”还是“被下单了多少次”举一个最简单的例子10个顾客点了同一道菜其中5个顾客每单点了2份另外5个顾客每单只点了1份。如果“销量”统计的是份数那么这道菜的销量就是 5×2 5×1 15如果统计的是下单次数那销量就是10。TOP10商品排行里的“销量”业务上显然指的是前者——商品被实际购买的总份数这才符合“哪个商品卖得多”的运营直觉。所以SQL里对明细表数量的聚合应该用SUM(number)而不是COUNT(*)。COUNT(*)数的是明细记录条数一单买三份只算一条记录会严重低估商品的实际销量。这个坑在统计数据结果异常时特别常见因为SQL不报错结果也“看起来合理”只有和真实业务对不上时才会发现问题。1.3 数据链路一条查询横跨三张表销量TOP10的数据来源链路是这样的订单表orders提供订单状态、下单时间用于过滤哪些订单才计入销量订单明细表order_detail提供商品ID、购买份数这是销量的直接来源商品表dish提供商品名称用于最终展示关系是订单明细通过order_id关联订单通过dish_id关联商品。一个订单下有多个明细一个明细对应一个商品。最终要生成的报表里只有商品名称和销量两个信息但为了拿到这两个信息必须从三张表里凑齐数据。我当时把这条链路在纸上画了一遍确定了查询逻辑先筛出时间区间内状态为“已完成”的订单再关联明细表和商品表按商品分组求和最后按销量倒序取前10条。这条思路听起来很顺但真正写代码的时候问题就藏在后面那些看起来不起眼的细节里。2. 后端实现销量统计SQL和接口组装2.1 核心SQL怎么写为什么这样写我先说最终验证通过的写法SELECT d.id AS dishId, d.name AS name, SUM(od.number) AS sales FROM order_detail od JOIN dish d ON od.dish_id d.id WHERE od.order_id IN ( SELECT id FROM orders WHERE status 5 AND order_time BETWEEN #{beginTime} AND #{endTime} ) GROUP BY d.id, d.name ORDER BY sales DESC LIMIT 10查询逻辑分三层第一内层子查询先把“已完成且时间符合”的订单ID筛出来。这里用子查询而不是直接 JOIN 订单表是为了避免订单表的字段被带进外层分组和排序让统计逻辑保持干净。如果直接把订单表 JOIN 进明细表再按商品分组虽然也能做但一旦后续增加过滤条件容易误伤分组结果。第二外层把明细按商品分组用SUM(od.number)统计每个商品卖出的总份数。这里需要注意GROUP BY后面写的是d.id, d.name不是只写d.name。因为理论上可能存在不同菜品同名的情况只按名称分组会把两个菜品合并成一条数据。按ID和名称一起分组保证统计粒度的准确性。第三ORDER BY sales DESC按销量倒序LIMIT 10取前十。这里用到了 SELECT 子句里定义的别名salesMySQL 在 ORDER BY 阶段可以直接引用这个别名所以排序逻辑很清晰。2.2 底层细节过滤条件不是随便写的订单状态值status 5是苍穹外卖项目里的“已完成”状态。我在写代码时没有把这个数字直接散落在SQL里而是在项目中定义了一个状态常量后续不管取数还是统计都引用同一个常量。这样万一状态定义调整只需要改一处。时间过滤的写法也值得注意接口层接收到的是LocalDate类型的 begin 和 end如果直接把这个值传给SQLMySQL 里的order_time是 datetime 类型会出现一个问题end 当天的时间范围只覆盖到 00:00:00当天晚些时候的订单全部被漏掉。所以我转换成了LocalDateTime beginTime LocalDateTime.of(begin, LocalTime.MIN); LocalDateTime endTime LocalDateTime.of(end, LocalTime.MAX);这样查询区间就覆盖到了结束日期的最后一秒。这个细节很容易被忽略但统计报表的日期边界错了数据会整体偏少而且不容易察觉。2.3 Controller、Service、Mapper的完整实现先看 Controller 层GetMapping(/top10) public ResultSalesTop10VO top10( RequestParam LocalDate begin, RequestParam LocalDate end) { return Result.success(reportService.getSalesTop10(begin, end)); }Service 层组装数据public SalesTop10VO getSalesTop10(LocalDate begin, LocalDate end) { LocalDateTime beginTime LocalDateTime.of(begin, LocalTime.MIN); LocalDateTime endTime LocalDateTime.of(end, LocalTime.MAX); // 先查出符合条件的订单ID ListLong orderIds orderMapper.getCompletedOrderIds(beginTime, endTime); if (orderIds null || orderIds.isEmpty()) { // 没有任何订单时直接返回空数据避免传给SQL一个空IN集合 return new SalesTop10VO(new ArrayList(), new ArrayList()); } ListMapString, Object dataList reportMapper.selectSalesTop10(orderIds); ListString nameList new ArrayList(); ListInteger numberList new ArrayList(); for (MapString, Object row : dataList) { nameList.add((String) row.get(name)); numberList.add(((Number) row.get(sales)).intValue()); } return new SalesTop10VO(nameList, numberList); }Mapper 接口ListMapString, Object selectSalesTop10(Param(orderIds) ListLong orderIds);Mapper XML 就是前面那一段 SQL只是把内层子查询换成foreach传入的ID集合select idselectSalesTop10 resultTypemap SELECT d.name AS name, SUM(od.number) AS sales FROM order_detail od JOIN dish d ON od.dish_id d.id WHERE od.order_id IN foreach collectionorderIds itemid open( separator, close) #{id} /foreach GROUP BY d.id, d.name ORDER BY sales DESC LIMIT 10 /select2.4 返回值设计为什么用两个List而不是对象List接口返回的对象是SalesTop10VO我设计成两个字段nameList和numberList。直接对应前端图表的数据源省去前端把对象数组拆成两个数组的过程。Data public class SalesTop10VO { private ListString nameList; private ListInteger numberList; }如果用对象数组大概长这样[{ name: 宫保鸡丁, sales: 120 }, { name: 水煮鱼, sales: 98 }]然后前端还要再手动拆两遍才能喂给 ECharts。既然图表需要的就是两个数组后端直接组装好接口契约更简单前端也少写几行代码。这个取舍在报表类接口里很常见核心原则就是“接口返回的数据最好能让前端直接使用”。到这里后端功能已经写完了理论上接口应该能正常返回商品名和销量。但刷新页面后看到了开头的那个奇怪现象。3. 前端图表商品名正常销量集体消失3.1 ECharts在Vue页面里的渲染流程前端用的是 Vue 管理后台框架图表组件是 ECharts。页面的核心逻辑很简单选择时间范围后调用GET /admin/report/top10拿到nameList和numberList组装 ECharts 的 option更新图表我当时写的核心代码const res await request.get(/admin/report/top10, { params: { begin, end } }); const { nameList, numberList } res.data; chart.setOption({ xAxis: { type: category, data: nameList }, yAxis: { type: value }, series: [{ type: bar, data: numberList, barMaxWidth: 40, label: { show: true, position: top } }] });这段配置的逻辑没有任何问题商品名数组给了X轴销量数组给了柱状图数据。如果两个数组都有值页面就应该是正常的柱状图。3.2 从UI反推问题层先别看前端配置看到“有名字没销量”的现象很多人第一反应是前端图表配置哪里写错了。但我当时的排查顺序反过来了先打开浏览器开发者工具看 Network 面板里接口到底返回了什么。这一步非常关键。因为从数据流的角度看页面显示的内容完全取决于接口返回的数据如果接口返回的numberList有值但页面不显示那才是前端渲染的问题如果接口返回的numberList本来就是空的或者全是 null那前端再怎么调配置也没用我当时点开接口响应看到的内容让我瞬间把排查重心从浏览器切到了后端{ nameList: [宫保鸡丁, 水煮鱼, 鱼香肉丝, 回锅肉, ...], numberList: [null, null, null, null, null, null, null, null, null, null] }商品名全部正常返回销量全是 null。ECharts 拿到 null 的时候柱子的高度自然就是 0悬浮提示也显示不了有效数值。这就解释了页面为什么看起来“有名字没销量”。3.3 前端渲染代码本身没有问题为了彻底排除前端嫌疑我在浏览器控制台打印了numberList并且手动构造了一组模拟数据传入图表chart.setOption({ series: [{ data: [120, 98, 75, 60, 55, 40, 35, 30, 22, 18] }] });柱子立刻正常显示。这说明 ECharts 配置、series 绑定、label 展示逻辑全部正常。问题被精确锁定到了后端接口返回的数据——numberList里为什么会全是 null。前端的问题排查到这里就结束了接下来进入真正的战场后端 Service 层。4. 根因定位map取数时key对不上4.1 在Service循环里断点观察map的实际内容在后端 Service 的 for 循环里打上断点重新请求接口用调试模式查看dataList的实际内容。IDE 里展开row这个 Map发现里面的 key 根本不是我以为的样子row { name 宫保鸡丁, sales 120 }也就是说SQL 查询结果确实成功返回了商品名和销量销量是120不是0也不是null。数据从一开始就是完好的。那问题出在哪再看我 Service 里的取数代码numberList.add(((Number) row.get(sales)).intValue());按道理row.get(sales)应该能拿到120。但实际运行时row.get(sales)返回的偏偏是 null。看到这里我心里已经有数了——最后确认一下数据库客户端里直接执行同一段SQL的列名返回情况。4.2 真正的坑MyBatis resultTypemap 的键名规则我用数据库客户端直接执行了 XML 里那段SQL查询结果集表头的列名非常明确第一列叫name第二列叫sales。这说明数据库返回的列名没有任何问题。但问题是MyBatis 在执行resultTypemap时每行记录会按结果集的列名作为 Map 的 key。也就是说返回的 Map 里的键名应该是name和sales这正是我正在取的两个 key。那为什么row.get(sales)会是 null我又反复确认了运行时返回的 map 内容发现执行到断点时row的 key 确实有sales。取数明明就该成功。后来我把注意力放到了另一个点这个row.get(sales)所在的 Service 代码和我实际运行的那份代码到底是不是同一个版本。查了一下问题找到了我当时为了接口返回/admin/report/top10Service 里最早写的是numberList.add(((Number) row.get(number)).intValue());而 XML 里写的别名是sales。一个取number一个别名叫salesMap 的 key 根本对不上。row.get(number)返回的自然是 null。前端拿到 numberList 全是 null表现就是“有名字没销量”。为什么商品名能正常显示因为 SQL 给商品名列写的别名是nameService 里取的 key 刚好也是name命名对上了一路顺通。而销量这一列SQL 里叫salesJava 里却用number去取命名不一致最终整个销量数据在组装阶段就丢掉了。4.3 resultTypemap 的隐藏规则很多人容易忽略这个坑的根源其实是对 MyBatis 的resultTypemap机制理解不够。它有几个重要特点第一Map 的 key 是数据库结果集的列名或别名原样返回不会做驼峰转换。SQL 里写AS salesMap 的 key 就是salesSQL 里写AS total_salesMap 的 key 就是total_sales而不是totalSales。想用map.get(totalSales)拿到total_sales的值没门。第二配置里的map-underscore-to-camel-case参数对resultTypemap不生效。很多人以为 MyBatis 开启了驼峰映射Map 的 key 也能自动从下划线转驼峰实际上这个配置只作用于 resultType 为自定义实体类的场景。用 Map 接收结果时key 就是数据库返回的原始列名。第三如果SQL里聚合列没有写别名结果集的列名通常是表达式本身不同数据库返回的规则还不一样。MySQL 里执行SUM(od.number)不带别名返回的列名一般就是SUM(od.number)这样的字符串。如果 Java 里用map.get(number)去取必然取不到。所以聚合列必须写一个明确的、可读的别名。我当时的问题就在第一和第三点之间摇摆SQL 里给了sales别名Map 的 key 理所应当是sales但我却用number去取。这不是框架的 bug是代码里命名没有对齐。4.4 修复方案统一SQL别名和取数key修复手段非常简单我选择了“只改Service取数key”的方案保持 SQL 里AS sales不变numberList.add(((Number) row.get(sales)).intValue());如果你偏爱number这个名字也可以把 SQL 里的别名改成AS number同时 Service 里保持map.get(number)。但要提醒一句number在部分数据库体系里存在被当作关键字或系统列名的风险从通用性和可读性角度sales、totalSales、salesCount这类带业务含义的名字更稳妥。修复后重新请求接口响应变成了{ nameList: [宫保鸡丁, 水煮鱼, 鱼香肉丝, 回锅肉], numberList: [120, 98, 75, 60] }前端图表立刻正常柱子有了高度tooltip 悬浮也能正确显示销量数字。4.5 顺带修正数值强转的隐患排查过程中我还发现了一个潜在问题MyBatis 返回的 Map 里数值字段的类型可能是Long、BigDecimal或Integer中的任意一种具体取决于数据库驱动和 SQL 上下文。如果直接写成(Integer) row.get(sales)当实际类型是Long时会直接抛 ClassCastException导致整个接口 500 报错。用Number作为中转类型再调用intValue()转换是最稳妥的写法((Number) row.get(sales)).intValue()Long、BigDecimal、Integer 都继承自 Number统一转成 int 完全没问题。5. 避坑速查还有哪些原因会造成“有名字没销量”5.1 一个快速定位问题层级的参考表“接口有名字、数据缺失”这类现象在前后端联调里非常容易出现。我结合这次排查经验整理了一张快速定位表遇到问题可以直接按这个路径查检查点操作方法可能结论Network 响应看接口返回里销量字段是否有值有值→前端问题无值或null→后端问题SQL 单独执行数据库客户端直接跑同一段SQL列名与取数key不一致→拿alias对不上Service 断点打印 map.keySet()key和取数写法不一致→统一命名前端 console打印销量数组undefined→字段名写错null→后端数据问题数据值都是0检查统计口径和过滤条件用了count而不是sum或状态过滤错误5.2 销量为0或null的几种隐藏原因除了我和标题里遇到的别名不一致的问题还有几种情况也会导致“有名字没销量”第一种SUM(number)的结果本身是 null。如果某组内所有明细的number都是 null聚合函数的结果就是 null而不是0。这种情况下接口返回的销量是 null前端同样显示异常。稳妥的做法是在SQL里用IFNULL(SUM(od.number), 0)包一层保证聚合结果至少是0。第二种状态过滤条件写错。比如把已完成状态写成了3或者别的值导致内层子查询查出来的订单ID集合为空外层查不到任何明细。这种情况下通常整个列表都是空的商品名也不会显示和“有名字没销量”略有区别但统计口径错误的表现形式很多值得列入排查范围。第三种订单明细表里存的是菜品名称快照但统计时用了商品主表的名字字段。如果同一菜品发生过改名或者存在历史脏数据按商品ID分组能拿到完整销量但商品名可能是null或旧名称。显示层面就会出现名称异常、甚至数据对不上。第四种前端 ECharts 的 tooltip 或 label 配置引用了不存在的字段。比如系列数据是数组但 label formatter 里写了params.data.sales而实际上数组元素是数字取不到属性tooltip 就显示不出数值。这类问题表现很像“没有销量”但数据源本身是正常的。5.3 字段命名的工程化建议这次排错最核心的问题只有一句话SQL 列别名和 Java 取数 key 不一致。但这类问题背后有一个更普遍的教训——字段命名统一性。我现在的习惯是聚合查询里每个列都写别名别名直接表达业务含义比如sales、orderCount、totalAmount。然后 Service 层取数代码里的 key 严格从 SQL 复制过来不要靠记忆手写。如果你用 IDE可以让 Mapper XML 的列名和 Java 里的常量形成对照如果你们团队有接口文档字段名也要和文档保持一致。还有一个很实用的自我验证办法接口写完先看一步返回的 JSON确认每个字段都有值再交给前端联调。这一步只需要几秒钟但能避免前端同学花一两个小时定位一个其实在后端的bug。6. 一段真实心得收尾这个销量TOP10的 bug修复起来真的就一行代码的功夫把map.get(number)改成map.get(sales)前端立刻恢复。但排查过程花了我大半天而且真正定位到根因之前我还经历了一遍“改前端→没用→改SQL→没用→再改前端”的无意义循环。后来我养成了一个习惯只要遇到“前端某个字段没有值”这种问题第一时间先看接口返回确认数据源头是对的再动前端代码。这个习惯帮我在后面好几个项目里省了大量时间。其实很多看起来诡异的前后端联调问题本质上都是数据格式或字段命名不一致造成的。如果你的页面也出现了类似“商品名字正常、销量却集体消失”的情况先把 Network 面板打开看看接口返回里销量字段到底是 null、0、还是连字段都没有这一步基本就能把问题范围缩小一半。剩下的一半就用断点或者日志打印一下 Map 的 key对照SQL里的别名很快就能找到答案。祝你在做苍穹外卖或者其他项目时能少踩这个坑。