☰
苍穹外卖统计模块实战:MySQL聚合查询与ECharts联调
2026/10/14 4:31:02 网站建设 项目流程

苍穹外卖这个项目,我做到第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 &gt;= #{begin} and create_time &lt; 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 date

MySQL 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 &gt;= #{begin} and o.create_time &lt; 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时,图表已经错过了绘制时机。

解决办法有两个,看场景选用:

  1. ECharts实例初始化放在容器真正显示出来之后,比如Tab切换的回调里。
  2. 容器已经初始化但宽度变化了,就调用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里用左闭右开的条件表达。这样既不会踩时区坑,也不会漏掉当天数据。把这个习惯固定下来,你后面做报表、统计、对账类功能时,都会少很多折腾。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询