做苍穹外卖的朋友应该都有过这种状态:功能没跑通的时候恨不得把电脑合上,跑通之后又在心里默念“Java也是瓦的苍穹外卖”——这项目真是自己一行一行抠出来的。我这两天刚把第11天的统计业务收尾,趁着思路还热乎,把完整复盘写下来。
所谓统计业务,其实就是管理端的四个报表:营业额统计、用户统计、订单统计、销量排名Top10。从代码量看,无非是“查数据 + 拼字符串”的CRUD,但真正动手之后才发现,难点全在“怎么把一个时间区间翻译成按天聚合”这件事上。前端要的是ECharts能直接消费的数据结构,而数据库里存的是一条一条分散的订单记录,这中间的转换逻辑才是这一天的核心。
这篇文章围绕这个核心,把从Mapper到Service再到VO的完整链路,连同我实际踩过的边界坑一起说清楚。不绕弯子,直接进入正文。
1. 动手写代码前,先理清四个报表的数据口径
1.1 统计模块的实际构成
苍穹外卖的统计业务分布在管理端的四个菜单里,每个菜单对应一个接口,也对应一张核心业务表。搞清楚它们各自查什么表、用什么字段,是整个统计业务的地基。我先把口径整理成了一张表:
| 报表 | 前端图表 | 核心表 | 关键字段 | 统计维度 |
|---|---|---|---|---|
| 营业额统计 | 折线图 | orders | amount, order_time, status | 按自然日分组求和 |
| 用户统计 | 折线图 | user | create_time | 按自然日统计新增 |
| 订单统计 | 折线图 + 表格 | orders | id, order_time, status | 按自然日统计数量 |
| 销量排名Top10 | 柱状图 | order_detail + orders | number, name, order_time, status | 按菜品名称聚合排序 |
注意一个容易被忽略的细节:营业额统计和订单统计的数据来源都是 orders 表,但统计口径完全不同。营业额统计关心的是“已完成的订单金额总和”,订单统计里的“订单总数”却要把所有状态的订单都算进去。同一个表,同一个时间区间,因为状态条件不同,返回的数据含义就完全不同。
1.2 前后端约定:为什么返回的是逗号分隔字符串
苍穹外卖的前端图表组件(ECharts)接受的数据是数组,但项目里这些统计接口的VO里,返回的却是用逗号拼接的字符串。
比如营业额统计的返回对象 TurnoverReportVO 里面是这样:
java复制public class TurnoverReportVO implements Serializable {
//日期,以逗号分隔,例如:2022-10-01,2022-10-02,2022-10-03
private String dateList;
//营业额,以逗号分隔,例如:406.0,1520.0,75.0
private String turnoverList;
}
前端拿到之后用 dateList.split(',') 就能还原成数组,直接塞给ECharts的xAxis和series。这个约定贯穿四个统计模块,所以后端这一层真正的任务只有两个:算准每一天的数据,然后按日期顺序拼成逗号分隔字符串。顺序不能乱,一旦乱了前端图表的数据就错位了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 营业额统计:逐日遍历 + sum聚合 + null兜底
2.1 为什么不能靠一句GROUP BY搞定
先看最容易想到的写法:把开始日期和结束日期传进去,按日期分组求和:
sql复制select DATE_FORMAT(order_time, '%Y-%m-%d'), sum(amount)
from orders
where order_time between #{begin} and #{end}
and status = 5
group by DATE_FORMAT(order_time, '%Y-%m-%d')
这个SQL写起来很痛快,但有个致命问题:如果某一天没有任何已完成的订单,分组结果里就不会出现这一天的记录。比如查询10月1日到10月7日,其中10月3日一单都没成交,那返回的结果就只有6行。而前端折线图需要7个点,第3天缺失会导致图形直接断掉。
所以正确的思路应该是:先在Java层生成一个从begin到end的完整日期列表,然后一天一天查询,当天没有数据就补0。这样不管中间有没有空档,返回的数据点始终是连续的。这是整个统计模块最核心的一个设计决策。
2.2 日期列表生成与当天时间区间构造
生成日期列表的代码,很多人第一版会这么写:
java复制List<LocalDate> dateList = new ArrayList<>();
dateList.add(begin);
while (!begin.equals(end)) {
begin = begin.plusDays(1);
dateList.add(begin);
}
这段代码能跑,但不建议直接这么用在Service方法里,因为 begin 是方法参数,你把它改了,后面如果还需要原始值就会出问题。我习惯用一个独立变量:
java复制private List<LocalDate> getDateList(LocalDate begin, LocalDate end) {
List<LocalDate> dateList = new ArrayList<>();
LocalDate current = begin;
dateList.add(current);
while (!current.equals(end)) {
current = current.plusDays(1);
dateList.add(current);
}
return dateList;
}
拿到日期列表之后,下一步是把每个 LocalDate 转成当天的开始时间和结束时间:
java复制LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(date, LocalTime.MAX);
LocalTime.MIN 是 00:00:00,LocalTime.MAX 是 23:59:59.999999999。用MAX而不是写死 23:59:59 是有讲究的,后面第7章我会单独讲这个坑。
2.3 Mapper动态SQL与null兜底
营业额查询的Mapper方法,原项目喜欢用Map传参:
xml复制<select id="sumByMap" resultType="java.lang.Double">
select sum(amount) from orders
<where>
<if test="begin != null">
and order_time >= #{begin}
</if>
<if test="end != null">
and order_time <= #{end}
</if>
<if test="status != null">
and status = #{status}
</if>
</where>
</select>
这个写法的好处是 sumByMap 方法可以复用——营业额统计传status,订单统计里的总营业额也传status,想要查询条件灵活变化时不用再写一个Mapper方法。Service里循环调用时,每个日期都要构造一次Map:
java复制Map<String, Object> map = new HashMap<>();
map.put("begin", beginTime);
map.put("end", endTime);
map.put("status", Orders.COMPLETED);
Double turnover = orderMapper.sumByMap(map);
turnover = turnover == null ? 0.0 : turnover;
turnoverList.add(turnover);
注意 sum(amount) 在没有任何满足条件的记录时返回的是 null,不是0。如果直接把null塞进List,后面 StringUtils.join 拼出来的字符串里会出现 "null",前端会解析失败。所以必须做null兜底。这个细节写成文章只有一行,但真跑起来报错时排查半天的人不在少数。
2.4 VO拼装与接口返回
所有日期的营业额都查完之后,最后拼VO:
java复制return TurnoverReportVO.builder()
.dateList(StringUtils.join(dateList, ","))
.turnoverList(StringUtils.join(turnoverList, ","))
.build();
StringUtils.join 用的是Spring自带的 org.apache.commons.lang3.StringUtils 也行。拼出来大概是这样的效果:
text复制dateList: 2025-03-01,2025-03-02,2025-03-03
turnoverList:406.0,0.0,1520.0
这里我建议营业额保留两位小数再拼接,避免出现 406.00000001 这种前端展示很尴尬的值。可以在查询出来的Double上做一次 new BigDecimal(turnover).setScale(2, RoundingMode.HALF_UP).doubleValue(),或者干脆在SQL里用 CAST(SUM(amount) AS DECIMAL(10, 2))。
3. 用户统计:新增用户和总用户数是两条不同的计算路径
3.1 新增用户按日count
用户统计的SQL比营业额简单得多,因为 user 表里的 create_time 就是用户注册时间。按日统计新增用户:
sql复制select count(id) from user
where create_time between #{beginTime} and #{endTime}
count(id) 在统计场景下和 count(*) 结果几乎一样,但 count(id) 会忽略id为null的记录——当然主键不会为null。我习惯用 count(id),语义上更明确:数的是用户,不是行数。
3.2 总用户数:累加比每次都count更聪明
这里有一个很值得分析的差异。用户统计报表除了要每天的新增用户数,还要展示截至每天结束时的总用户数,也就是存量概念。很多人的第一反应是每天查一次 count(*) from user where create_time <= 当天结束时间,这样也能做,但一个查询区间如果是30天,就要执行31次count,而且每次count都是全表扫描(在create_time没有索引的情况下),性能很不好看。
更聪明的做法是:
- 先查一次截止到开始日期前一天的总用户数
baseUserCount; - 按天查新增用户数;
- 从第1天开始,
totalUser = baseUserCount + 新增用户数,然后逐天累加。
翻译成代码:
java复制// 查询开始日期之前的总用户数
LocalDateTime beginTime = LocalDateTime.of(begin, LocalTime.MIN);
Long baseUserCount = userMapper.countByMap(map);
List<Long> newUserList = new ArrayList<>();
List<Long> totalUserList = new ArrayList<>();
Long totalUser = baseUserCount;
for (LocalDate date : dateList) {
LocalDateTime dateBegin = LocalDateTime.of(date, LocalTime.MIN);
LocalDateTime dateEnd = LocalDateTime.of(date, LocalTime.MAX);
Long newUser = userMapper.countByMap(dateBegin, dateEnd);
newUserList.add(newUser);
totalUser += newUser;
totalUserList.add(totalUser);
}
这样整个周期里只执行一次“查存量”的SQL,其余都是按天查新增。看似只优化了几次查询,但在报表场景里,接口响应时间的提升是立竿见影的。
这里顺便说一句:每天新增用户数的查询,在SQL层面尽量走 create_time 索引。我在本地测试的数据量不大感觉不到差距,但数据一旦上十万行,没有索引的 between 查询会很吃力。
3.3 UserReportVO的结构
用户统计返回的VO也是字符串约定:
java复制private String dateList;
private String totalUserList;
private String newUserList;
注意totalUserList和newUserList是两个list分开拼的,前端在同一个图表上画两条折线。所以在Service里务必保证两个List的索引一一对应,第i个totalUser一定是第i天结束时的总用户数,第i个newUser也必须是第i天的新增数,不能错位。
4. 订单统计:完成率背后的状态机与防除零
4.1 “有效订单”到底是什么状态
订单统计报表有三个核心数字:订单总数、有效订单数、订单完成率。其中订单总数很好理解,就是时间区间内产生的所有订单;有效订单的定义则是 状态为“已完成”的订单。
苍穹外卖里订单状态是个枚举值:
| 状态值 | 含义 |
|---|---|
| 1 | 待付款 |
| 2 | 待接单 |
| 3 | 已接单 |
| 4 | 派送中 |
| 5 | 已完成 |
| 6 | 已取消 |
有效订单对应 status = 5(Orders.COMPLETED)。只有这个状态才代表订单最终成功履约,金额也才真正进入了营业额。所以订单统计里的“有效订单数”,和营业额统计里的“营业额”,业务含义是一致的——它们都只认已完成订单。
4.2 订单总数、有效订单数、营业额的复用查询
订单统计的Service实现,和营业额统计是同一个Mapper方法的重用。三个指标里,订单总数和有效订单数用的是 count,营业额用的是 sum。我做了两个Map查询:
java复制// 统计区间内的订单总数(不区分状态)
Map<String, Object> totalMap = new HashMap<>();
totalMap.put("begin", beginTime);
totalMap.put("end", endTime);
Integer totalOrderCount = orderMapper.countByMap(totalMap);
// 统计区间内的有效订单数和营业额
Map<String, Object> validMap = new HashMap<>();
validMap.put("begin", beginTime);
validMap.put("end", endTime);
validMap.put("status", Orders.COMPLETED);
Integer validOrderCount = orderMapper.countByMap(validMap);
Double totalOrderAmount = orderMapper.sumByMap(validMap);
这正是前面说的 countByMap + sumByMap 的复用价值。一个Mapper方法满足多个统计需求,代码量少,逻辑也一致,不会出现两个统计对“有效订单”定义不一致的情况。
4.3 完成率计算与除零保护
订单完成率的公式是:
text复制订单完成率 = 有效订单数 / 订单总数
这里最容易翻车的就是除零异常。如果统计区间内一个订单都没有,totalOrderCount 为0,直接相除会抛 ArithmeticException。所以必须加保护:
java复制Double orderCompletionRate = 0.0;
if (totalOrderCount != 0) {
orderCompletionRate = validOrderCount.doubleValue() / totalOrderCount;
}
计算出来是一个0到1之间的小数,比如 0.8571428571428571。前端拿到后会乘以100再显示成百分比。
这里我额外做了一个处理:因为订单完成率这个值经常被前端直接展示,我会保留四位小数:
java复制BigDecimal rate = new BigDecimal(orderCompletionRate);
orderCompletionRate = rate.setScale(4, RoundingMode.HALF_UP).doubleValue();
这样前端显示就是 85.71%,而不是一长串小数。数据口径没问题,但是展示层的体验也要照顾到。
另外要注意,订单统计的时间条件用的是 order_time(下单时间),而不是 checkout_time(结账时间)。虽然这两个字段在已完成订单里相差不大,但统计口径必须明确:苍穹外卖的营业额和订单统计都以下单时间为准,这个口径和前端布局一致。如果混用,同一笔订单可能被算到不同的日期里。
5. 销量排名Top10:join条件、分组维度与排序陷阱
5.1 关联查询的SQL写法
销量排名统计的是某个时间区间内,销量最高的10款商品。它不能只查 orders 表,还需要 order_detail 表,因为每笔订单里可能有多个菜品,每个菜品的数量都记录在 order_detail.number 里。
SQL如下:
xml复制<select id="getSalesTop10" resultType="com.sky.dto.GoodsSalesDTO">
select od.name, sum(od.number) number
from order_detail od
left join orders o on od.order_id = o.id
where o.status = 5
and o.order_time between #{begin} and #{end}
group by od.name
order by number desc
limit 10
</select>
两个细节值得说。
第一,用left join还是inner join。这里 where 条件里有 o.status = 5,left join左侧表的数据如果找不到匹配的orders记录,o.status就是null,会被where过滤掉。所以实际执行效果等同inner join。我建议直接写inner join,语义更清晰,也方便以后维护的人理解。
第二,排序前的limit位置。order by number desc limit 10 是先按销量倒序,再取前10条。如果把limit写前面,就变成先取10条再排序,结果完全错误。这个顺序问题在MyBatis XML里不像SQL编辑器那么显眼,拼SQL的时候要注意。
5.2 按name分组还是按dish_id分组
原项目里用了 group by od.name,这在课程项目里没问题,但放到真实业务中就有隐患了。order_detail 表里有 dish_id 和 setmeal_id 字段,一个菜品在不同订单里的名称理论上是一致的,但万一菜品改过名,新订单和旧订单的name就不同了,按name分组会把同一个菜品拆成两行。
更稳妥的分组维度是:如果是菜品,按 dish_id 分组;如果是套餐,按 setmeal_id 分组。但报表展示的是商品名称,所以分组后要取一个代表name,MySQL里可以直接不取,因为这个项目里同一dish_id的name一致;更严格的写法是 group by od.dish_id, od.setmeal_id,然后 max(od.name) 作为展示名称。
课程阶段用 group by od.name 可以,面试时如果能把上面这个隐患分析出来,会是一个加分点。
另外,sum(od.number) 返回的类型在MyBatis里可能是 BigDecimal 或 Long。如果 GoodsSalesDTO.number 声明的是Integer,可能会有类型转换异常;声明成Integer更保险:
java复制@Data
public class GoodsSalesDTO implements Serializable {
private String name;
private Integer number;
}
如果SQL里sum出来带小数,建议在SQL里直接 CAST(SUM(od.number) AS SIGNED) 或 CONVERT(SUM(od.number), SIGNED),从源头保证类型干净。
6. 优化:按天分组一次查完,内存补零替代逐日查库
6.1 逐日查询在长区间下的问题
前面营业额统计和用户统计的方案,都是“遍历每一天,查一次数据库”。这在时间区间只有7天、30天的时候完全没问题,但如果用户选了1月1日到12月31日,那就是365次SQL。每次SQL虽然很快,但累积起来的网络开销和数据库连接占用就不容忽视了。我曾经在连续调用几个统计接口后,本地连接池一度打满,虽然和项目本身的配置有关,但逐日查库确实放大了这个问题。
6.2 按天分组 + 内存合并的实现
在真正需要面对长区间的场景下,我推荐一种“一次查完,内存补零”的方案。核心思路是:
- 先用SQL按天分组查出来有数据的日期和统计值;
- 在Java里遍历完整日期列表,从结果Map中取值,取不到就补0。
营业额统计的优化版本可以这样写。SQL:
xml复制<select id="sumByDateRange" resultType="java.util.Map">
select date_format(order_time, '%Y-%m-%d') dateStr,
sum(amount) amount
from orders
where order_time between #{begin} and #{end}
and status = 5
group by date_format(order_time, '%Y-%m-%d')
</select>
Service:
java复制List<Map<String, Object>> rows = orderMapper.sumByDateRange(beginTime, endTime);
Map<String, Double> map = new HashMap<>();
for (Map<String, Object> row : rows) {
String dateStr = (String) row.get("dateStr");
Double amount = ((Number) row.get("amount")).doubleValue();
map.put(dateStr, amount);
}
List<String> turnoverList = new ArrayList<>();
for (LocalDate date : dateList) {
Double amount = map.getOrDefault(date.toString(), 0.0);
turnoverList.add(amount.toString());
}
这样无论区间多长,SQL都只执行一次。数据库层的分组聚合是它的强项,内存里的Map取值也很快,整体性能比365次查询好一个量级。
同理,用户统计的“新增用户数”和订单统计的“订单数”都可以用这种方式。唯一的缺点是要写按天分组的SQL,并在Java里做一次日期字符串的匹配,代码稍微啰嗦一点,但对熟悉Java8流式处理的人来说并不复杂。
6.3 两种方案怎么选
我的建议是:课程项目和中小型项目优先用逐日查询方案,代码直白、易读、易维护;如果你已经预见到统计区间可能很长,或者统计接口会被频繁调用,才需要升级到“按天分组+内存补零”。不要一上来就优化,统计业务的核心是口径正确,先跑通、再优化,这个顺序不能反。
7. 实测踩过的边界坑:日期格式、时间边界与SQL转义
7.1 LocalTime.MAX和23:59:59的差别
这是个非常细但很关键的坑。有人写一天结束时间时会用:
java复制LocalDateTime.of(date, LocalTime.of(23, 59, 59))
这样当天 23:59:59.5 下单的订单就会被漏掉。虽然现实里这种概率极低,但统计口径要的是严谨,不能靠运气。正确做法是用:
java复制LocalDateTime.of(date, LocalTime.MAX)
LocalTime.MAX 等于 23:59:59.999999999,把这一天的最后一纳秒都包含了。同理,开始时间用 LocalTime.MIN(00:00:00),不要把 00:00:00 写成 LocalTime.MIDNIGHT,两者虽然值一样,但MIN语义更明确。
7.2 时间参数格式的前后端统一
Controller接收参数时,前端传的是 2025-03-01 这种日期字符串,所以必须在Controller入参上加格式注解:
java复制@GetMapping("/turnoverStatistics")
public Result<TurnoverReportVO> turnoverStatistics(
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end) {
// ...
}
如果漏了这个注解,Spring在解析时会直接报 DateTimeParseException,前端看到的错误就是500。还有一种情况是前端传了 2025-03-01 00:00:00 这种带时间点的字符串,也需要跟前端约定好统一格式。我在本地联调时就遇到过前端时间组件默认输出带时分秒的情况,排查了好一会儿。
7.3 XML里特殊符号的转义
MyBatis XML里写动态SQL时,> 和 < 符号要特别小心。比如 order_time >= #{begin} 里的 > 一般没问题,但 < 会被XML解析器当成标签开头,必须写成 <:
xml复制<if test="end != null">
and order_time <= #{end}
</if>
这是老生常谈,但总有人忘。忘了之后启动项目时XML解析报错,或者SQL直接拼错,排查起来比业务逻辑错误更隐蔽。还有一个是 <= 要写成 <=,不是 <= 中间加空格,写错的话等于号会丢。
7.4 别在SQL里到处用DATE_FORMAT
如果SQL里用 DATE_FORMAT(order_time, '%Y-%m-%d') 做分组,MySQL里这个写法会放弃索引扫描,因为对字段做了函数运算。数据量小无感,数据量大了就是个隐患。所以在时间和索引敏感的查询里,我更倾向于用 between 构造区间,而把“按天分组”的工作交给Java层的日期遍历,或者是用 DATE_FORMAT 但接受它全表扫描。
这种取舍没有绝对的对错,关键是心里要有数:你选择某种写法,必须知道它在数据量变大后会付出什么代价。
最后说一点我的真实体会。做统计业务很像做一次“数据对账”,SQL谁都会写,难的是把每一个数字背后的口径想清楚。营业额到底认哪个状态、用户存量什么时候统计、完成率除零怎么办、日期边界覆盖到哪一秒,这些问题全部想明白了,你写出的代码才是真正能上生产的代码。我第一版做完之后跟前端联调,前三个图表一次通过,唯独总用户数对不上,最后发现就是“存量用户”的起始计算日期差了一天。修完之后再看这段代码,其实逻辑很简单,但当时就是被“想当然”坑了。
所以如果你也正在做苍穹外卖的统计业务,别急着写代码,先花十分钟把口径列出来,写清楚每天查什么、怎么拼、边界在哪,后面会顺很多。这一天的“Day 11”,值就值在这些细节里。
