苍穹外卖这个项目,我做到第10天的时候,任务突然从“做功能”变成了“看数据”。前面几天还在写员工管理、菜品分类、购物车和下单那一套,到了第10天,核心就一件事:把后台的统计模块做出来,让运营能按时间范围直观地看到营业额、用户增长、订单情况和菜品销量排名。这里说的统计模块,指的不只是一句SQL,而是从MySQL聚合查询、Spring Boot接口到前端ECharts图表的完整链路。当天做完以后,我觉得这也是整个项目里最值得拿出来复盘的一环,因为数据统计看似简单,坑都藏在各种边界条件里。
如果你也正在做类似的餐饮管理后台项目,或者正准备接触数据可视化模块,这篇主要写给两类人:一类是刚把增删改查写完、想看看统计报表怎么设计的新手;另一类是用过ECharts但总在图表联调上出问题的人。我会把第10天的功能拆解、后端实现、前端联调和踩坑记录一次说清楚。
1. 数据统计模块到底在统计什么
1.1 工作台页面的需求场景
先把这个模块的功能范围讲清楚。后台管理系统里,运营或店长登录以后,第一眼看到的往往不是订单列表,而是一个“工作台”首页,上面摆着今日营业额、待接单数量、待配送数量等核心指标。第10天做的事情,就是在工作台基础上加了一段“按时间范围查询”的图表统计能力。
具体到页面,常见的是这样几个区块:
- 顶部放四个统计卡片:今日营业额、今日订单数、新增用户数、有效订单完成率。
- 中间是一张营业额趋势图,默认展示最近7天。
- 下面可能是用户增长曲线,或者订单状态分布饼图。
- 再往下是菜品销量排行Top10。
前端通常用ECharts来画折线图、柱状图和饼图,后端负责提供结构化的JSON数据。也就是说,第10天表面上是“写统计接口”,实际上要打通三层:MySQL里的聚合查询、Controller返回的数据结构、前端图表接收的格式。
这里我建议先别急着写代码,而是先把“要算哪些指标”列清楚。以苍穹外卖这个项目为例,核心指标无非四个:营业额、用户数、订单量、菜品销量。但这四个指标不能随便查,必须确定数据口径,否则一个指标有偏差,整张图表都是错的。
1.2 统计口径:哪些订单算数
数据统计最容易翻车的地方,不是SQL不会写,而是“口径没对齐”。比如营业额,你到底是统计所有订单的总金额,还是只统计“已完成”的订单?如果用户下单后取消,或者支付后申请退款,这单的钱还能算营业额吗?
我当时的做法是:先看项目的订单状态枚举,把状态码梳理一遍。常见外卖项目里订单状态可能有1待付款、2待接单、3已接单、4配送中、5已完成、6已取消。所谓“有效订单”,一般指已经完成的订单,也就是状态为5的记录。营业额统计、订单完成率、菜品销量Top10,全都基于这个口径。
有个容易忽略的细节:订单表里的金额字段可能会分“订单金额”和“实收金额”。如果包含优惠、满减、配送费减免,两者就会有偏差。营业额应该以用户实际支付的金额为准,也就是实收金额,不要拿订单原价去算。这个细节在功能演示时可能看不出问题,但运营对账的时候一定会发现。
所以我在做接口之前,先把这些规则写成了注释放在Service方法头上:
/** * 营业额统计口径: * 1. 只统计状态为“已完成(5)”的订单 * 2. 金额取实收金额字段 * 3. 时间范围包含开始日期和结束日期,按自然日分组 */别嫌这一步麻烦,等到联调后期再去改口径,改动面会大得多。
2. 后端统计接口:从SQL到返回结构
2.1 营业额统计接口的完整实现
先看后端最核心的营业额统计接口。前端说“我要查1月1日到1月7日每天营业额”,后端就要返回两个对齐的数组:一个是日期列表,一个是营业额列表。
我先定Controller接口,参数直接用LocalDate接收,方便后面做日期计算:
@GetMapping("/statistics/turnover") public Result<TurnoverReportDTO> turnover( @RequestParam LocalDate begin, @RequestParam LocalDate end) { List<LocalDate> dateList = getDateList(begin, end); List<BigDecimal> turnoverList = statisticsService.getTurnoverList(begin, end, dateList); return Result.success(new TurnoverReportDTO(dateList, turnoverList)); }Service里要做的第一件事,是先把日期范围内的每一天都生成出来。这一步必须做,因为数据库里可能某一天没有任何订单,SQL聚合结果里压根没有这一天的记录,但图表上仍然要显示这一天的营业额,值为0。
日期生成逻辑很简单:
private List<LocalDate> getDateList(LocalDate begin, LocalDate end) { List<LocalDate> dateList = new ArrayList<>(); LocalDate current = begin; while (!current.isAfter(end)) { dateList.add(current); current = current.plusDays(1); } return dateList; }然后写SQL查询。我建议这种聚合SQL放到Mapper XML里,而不是用注解写在Java代码上,因为以后改动概率很大,XML改起来不用重新编译,可读性也更好。
<select id="getDailyTurnover" resultType="map"> select date_format(create_time, '%Y-%m-%d') as date, sum(amount) as turnover from orders where status = 5 and create_time >= #{begin} and create_time < DATE_ADD(#{end}, INTERVAL 1 DAY) group by date_format(create_time, '%Y-%m-%d') order by date </select>这里有个非常重要的细节:查询条件是左闭右开,也就是>= begin和< end + 1天。为什么不用<= end?因为create_time是datetime类型,如果传进来的end是2025-01-07,那么create_time <= '2025-01-07'只包含了1月7日0点0分0秒以前的数据,当天的订单全都被漏掉了。这种边界坑在统计模块里属于必踩项,我后面还会专门复述。
Service层拿到Mapper返回的List之后,把结果转成Map结构,再遍历日期列表补齐0值:
Map<LocalDate, BigDecimal> map = new HashMap<>(); for (Map<String, Object> row : rows) { LocalDate date = LocalDate.parse((String) row.get("date")); BigDecimal turnover = new BigDecimal(row.get("turnover").toString()); map.put(date, turnover); } List<BigDecimal> turnoverList = new ArrayList<>(); for (LocalDate date : dateList) { turnoverList.add(map.getOrDefault(date, BigDecimal.ZERO)); }这样前端拿到的就是长度一致的两个数组,第几个日期对应第几个营业数据,不会错位。
2.2 用户统计和订单统计的SQL差异
用户统计要返回两个维度:每日新增用户数,以及截止到每天的总用户数。每日新增用户好查,就是用户在当天第一次创建账号的数量:
select date(create_time) as date, count(*) as cnt from user where create_time >= #{begin} and create_time < DATE_ADD(#{end}, INTERVAL 1 DAY) group by date(create_time)难点在于“总用户数”。如果你打算在Java里写一个循环,每天执行一次count(*),那数据量小的时候没问题,但接口响应时间会很难看,而且写起来特别蠢。我当时直接让SQL把累计结果也算了:
select date, sum(cnt) over (order by date) as total from ( select date(create_time) as date, count(*) as cnt from user where create_time >= #{begin} and create_time < DATE_ADD(#{end}, INTERVAL 1 DAY) group by date(create_time) ) t order by dateMySQL 8 支持窗口函数,这个写法很干净。查询结果里每一行是“某天新增多少、截止这天一共多少”,前端直接画两条折线就行。
订单统计稍微综合一点。一般的需求是看每天“总订单数”和“有效订单数”,顺便算一下完成率。一条SQL就能搞定:
select date_format(create_time, '%Y-%m-%d') as date, count(*) as totalOrders, sum(case when status = 5 then 1 else 0 end) as validOrders from orders where create_time >= #{begin} and create_time < DATE_ADD(#{end}, INTERVAL 1 DAY) group by date_format(create_time, '%Y-%m-%d') order by date完成率不要在SQL里除,因为可能出现某天总订单数为0的情况,除数为0会直接报错。放到Java里判断:
if (totalOrders == 0) { completionRate = BigDecimal.ZERO; } else { completionRate = BigDecimal.valueOf(validOrders) .divide(BigDecimal.valueOf(totalOrders), 4, RoundingMode.HALF_UP); }2.3 菜品销量Top10的聚合方式
菜品销量排行要侧重“商品维度”,常见做法是拿订单明细表去聚合。需要注意两点:一是只能统计已完成订单里的商品,否则取消的订单也会把销量算进去;二是如果同一个菜品在多笔订单里出现,要按菜品名称分组求和。
<select id="getTop10Dishes" resultType="map"> select od.name as name, sum(od.number) as sales from order_detail od inner join orders o on od.order_id = o.id where o.status = 5 and o.create_time >= #{begin} and o.create_time < DATE_ADD(#{end}, INTERVAL 1 DAY) group by od.name order by sum(od.number) desc limit 10 </select>为什么按od.name分组而不是od.dish_id?因为如果菜品被下架、删除或修改过名称,订单明细里仍然应该保留下单那一刻的名称。按名称分组虽然简单,但在“同一菜品改过名”的情况下会把数据分成两条,严格来说应该按菜品id分组。我做项目用的是按od.name,因为功能演示要求不高,而且明细表在设计时已经冗余存了商品名称。真实商业项目里,我建议优先用goods_id这类稳定字段去分组。
3. 前端图表联调:ECharts如何接住这些数据
3.1 后端返回结构怎么设计最顺手
统计接口的返回结构,直接影响前端写起来顺不顺手。我见过有人把接口设计成[{date:'2025-01-01', value:123}, ...],前端也能用,但ECharts的x轴数据还得单独抽出来,多一道转换。最简单的方式是后端直接给两个对齐的数组:
{ "code": 1, "data": { "dateList": ["2025-01-01", "2025-01-02", "2025-01-03"], "turnoverList": [120.00, 0.00, 356.50] } }前端拿到以后,连数据加工都不用,直接这样绑定:
function loadTurnoverChart() { axios.get('/admin/statistics/turnover', { params: { begin: dateRange.begin, end: dateRange.end } }).then(res => { const data = res.data; turnoverChart.setOption({ xAxis: { data: data.dateList }, series: [{ data: data.turnoverList }] }); }); }日期格式统一用yyyy-MM-dd,别返回带时间戳或带T的ISO字符串,否则ECharts的类目轴会显示得很奇怪。
3.2 默认时间范围和空白数据处理
页面一打开的时候,数据是空的,前端需要先给一个默认时间范围。通常取“最近7天”,也就是从今天往前推6天。这里有一个新手容易踩的点:日期选择器用的组件,返回值可能是字符串、Date对象或时间戳,一定要统一转换后再传给后端。
我当时写了一个比较稳妥的方法:
function getDefaultRange() { const end = new Date(); const start = new Date(); start.setDate(end.getDate() - 6); return { begin: formatDate(start), end: formatDate(end) }; } function formatDate(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; }另外,图表上没有数据的时候,比如刚上线还没有任何订单,后端返回的列表可能全是0。对于折线图、柱状图来说没问题,直接显示0即可;但饼图会有麻烦,如果所有值都是0,ECharts通常不会渲染,页面就会空白。处理办法是在前端判断一下汇总值,为0的时候给出一个“暂无数据”的提示,而不是干等一张空图。
3.3 图表刷新和销毁的细节
关于ECharts实例,我发现很多经验不足的人容易犯一个错:在组件里初始化了一个图表实例以后,整个页面到处用同一个变量,切换日期范围以后图表不更新,也不调用setOption的覆盖逻辑。这里我的经验是,每个图表容器单独维护一个实例,切换查询条件时统一走同一个setOption,而且要带上notMerge: true,保证旧数据被完整清掉:
chart.setOption(option, true);页面销毁时记得调用chart.dispose(),否则页面频繁切换会出现内存泄漏或图表重叠的问题。
4. 联调过程里我踩过的几个真实问题
4.1 时间边界导致统计少了当天数据
这个问题我在上面提到过,但因为它太典型,值得单独展开。当时联调时,前端选择1月1日到1月7日,结果1月7日当天无论怎么下单,营业额统计里都没有数据。我排查了一圈,发现问题出在SQL条件写成了:
where create_time >= #{begin} and create_time <= #{end}这个条件的实际效果是查询到1月7日0点0分0秒就截止了。当天上午10点的订单,因为create_time > 2025-01-07 00:00:00,全部被过滤掉。解决办法就是统一改成左闭右开:
where create_time >= #{begin} and create_time < DATE_ADD(#{end}, INTERVAL 1 DAY)从那以后,我写所有时间范围查询都默认左闭右开,这个习惯帮我避开了一整类bug。
4.2 MySQL时区引起的统计偏差
还有一个隐藏很深的问题,就是时区。数据表里有一条订单创建时间是中午12点,但统计结果显示它跑到了前一天。这种情况发生在数据库和服务端连接时区设置不一致的时候。
我当时检查了JDBC连接串,发现配置里没写serverTimezone,MySQL默认用的是服务器本地时区,而Java应用侧用的是一套默认时区,两边一换算就偏了。解决办法很直接,在数据库连接串里显式指定:
spring.datasource.url=jdbc:mysql://localhost:3306/sky_take_out?serverTimezone=Asia/Shanghai&useSSL=false如果是Docker部署的MySQL,容器里的默认时区也可能是UTC,那就要在容器启动时加时区参数,或者在配置里统一指定。这个问题在本地开发时不容易暴露,一旦部署到不同环境的服务器上,统计结果就可能出现莫名其妙的偏差。
4.3 累计用户数SQL写法的优化过程
我第一次实现“总用户数”的时候,图省事,写了一个“每天查一次总数”的循环。测试时数据量小,感觉还行;后来导入了模拟数据,接口一下子卡了好几秒。因为每查一天都要全表扫一次User表,7天就是7次,30天就是30次。
后来改成上面说的窗口函数,一次查询把每日新增和累计都算出来,响应时间从几秒降到了几十毫秒。这个优化的核心思路是:能用SQL在数据库里完成的聚合统计,就不要把明细数据拉到Java里再算。统计模块尤其要注意这点,因为数据量一大,Java层循环做累加的性能完全不可控。
4.4 图表容器宽度为0的经典坑
还有一个前端问题,卡了我将近一个小时。图表用在弹窗或Tab页里时,初始化执行的时候容器还没有显示出来,宽度直接取到0,结果图总是画不出来或者变形。
我遇到的具体场景是:一个页面上有两个Tab,第一个Tab是表格,第二个Tab放图表。进入页面时ECharts已经初始化了,但第二个Tab还没渲染,容器宽高都是0。等切换到第二个Tab时,图表已经错过了绘制时机。
解决办法有两个,看场景选用:
- ECharts实例初始化放在容器真正显示出来之后,比如Tab切换的回调里。
- 容器已经初始化但宽度变化了,就调用
chart.resize()重新计算。
我在代码里做了个封装,切换Tab时统一调一下:
this.$nextTick(() => { turnoverChart.resize(); userChart.resize(); });4.5 排查问题用到的几个实用手段
联调时遇到“数字不对”的情况,我的排查顺序一般是先确认口径,再查SQL,最后看前端数据转换。
一个特别好用的手段是在开发环境临时开启MyBatis的SQL日志,把实际执行的SQL和控制台打印出来的SQL对比,看看传入的时间参数到底拼接成了什么。反正我遇到过不止一次,Controller层传进来的是LocalDate,一不留神被转成了带时分秒的字符串,导致SQL查询条件完全变了。
另一个手段是先把后端接口返回的JSON直接贴到浏览器或Postman里看,确认后端数据没问题再去找前端问题。前后端联调时最忌讳两边同时怀疑,规范的做法是先固定一端,再排查另一端。
我把这些经验整理成一个速查表,算是给后面做统计模块的朋友一个参考:
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 当天数据缺失 | 时间范围用了<= end | 改成< end + 1天 |
| 数据整体偏到前一天 | 时区不一致 | 连接串指定serverTimezone |
| 图表不显示 | 容器宽高为0 | 容器显示后再初始化或调用resize() |
| 某个日期没有数据 | 数据库当天无记录 | 后端补齐0值 |
| 完成率超过100% | 分子分母口径不一致 | 分子必须是已完成订单数 |
| 累计用户数计算慢 | Java循环多次查询 | 用窗口函数一次查出 |
5. 统计模块做完整之后,还能加什么
第10天要求的功能做完以后,页面能看、数据能查、图表能跳,已经算达标了。但如果想把这套模块做得更有价值,我额外做了几个小扩展,成本都不高。
第一个是给统计接口加统一的参数校验。搜索条件里的开始日期不能晚于结束日期,时间跨度不能超过一年,否则就抛出异常。这个校验看起来琐碎,但能防止用户传了一个超大时间范围,把数据库拖垮。
第二个是考虑加一层缓存。统计数据的实时性没那么高,一个运营页面不需要精确到分钟级。当天之前的数据基本不会变了,完全可以缓存起来,只有“今天”的数据需要实时查。这种思路在真实项目里很常见,不过第10天阶段先不急着上Redis,保持逻辑简单更重要。
第三个是思考要不要做门店维度。苍穹外卖这个项目本身是单体门店,统计不需要区分门店。但真实的外卖系统通常会有多个门店,营业额、用户、订单统计都要支持按门店过滤。这时候接口里的查询条件就得加一个shopId,SQL里也多一个分组维度。虽然第10天没要求,但把这个思路预留到设计里,后面扩展起来会省很多事。
说回第10天本身。统计模块和前面几天的增删改查不太一样,它更强调对业务的理解,而不是纯粹的接口堆砌。同样一张订单表,不同人写的统计SQL可能结果完全不一样,差别就在口径上。我个人在做完这个模块以后最大的体会是:画图永远不是重点,重点是让图上的每一个数字都经得起推敲。
最后再分享一个小技巧:联调这种带时间范围的接口时,不要把日期参数在代码里传来传去,而是统一在Controller层接收LocalDate,在SQL里用左闭右开的条件表达。这样既不会踩时区坑,也不会漏掉当天数据。把这个习惯固定下来,你后面做报表、统计、对账类功能时,都会少很多折腾。