

🔥个人主页:北极的代码(欢迎来访)
🎬作者简介:java后端学习者
✨命运的结局尽可永在,不屈的挑战却不可须臾或缺!
前言:
前面我们完成了苍穹外卖的接单提醒和催单业务功能,具体实现逻辑,下一阶段,我们继续实现苍穹外卖的其他业务功能,主要是报表的可视化统计,自然而言的就要用到相应的工具,Apache ECharts就是不二之选,我们具体讲解,从了解这个工具到在实战中使用这个工具。
Apache ECharts是什么:
Apache ECharts 是一个基于 JavaScript 的开源可视化图表库。
简单来说,它是一套用于在网页上绘制交互式图表的工具。它由 Apache 软件基金会孵化并维护,前身是百度研发的 ECharts。
它的核心特点:
丰富的图表类型
它几乎涵盖了所有常见的数据可视化图形,包括:折线图、柱状图、饼图、散点图、雷达图、地图(支持地理坐标)、热力图、关系图(如知识图谱)、树图、仪表盘等。
强大的交互性
不仅只是静态图片,ECharts 生成的图表支持:
鼠标悬停显示数据详情。
图例切换(点击图例可以隐藏或显示某条数据线)。
区域缩放(通过鼠标框选或滑动条查看数据细节)。
动画效果。
高度可定制
你可以通过配置项(
option)精确控制图表的颜色、字体、坐标轴、网格、提示框等几乎所有视觉元素,适配不同的设计风格。跨平台与高性能
它基于 HTML5 Canvas(画布)技术,在电脑端、手机端、平板端都能流畅运行。对于大数据量的展示(如数万甚至数十万个数据点),它通过增量渲染等技术依然能保持良好的流畅度。
使用简单
你只需要在 HTML 页面中引入一个 JavaScript 文件,准备一个具有一定高度的
<div>容器,然后通过 JavaScript 代码配置数据项即可生成图表。它与 Vue、React 等现代前端框架也能很好地集成。
常见应用场景:
企业级 BI 系统(数据看板、管理后台)。
运营数据监控(实时流量、销售额走势)。
科研与统计分析。
地图数据可视化(利用其内置的地图坐标系)。
总结 :如果你需要在网页上把枯燥的表格数据变成美观、可交互、能钻取查看详情的图表,Apache ECharts 是目前业界非常成熟且免费的开源解决方案。
营业额统计业务需求分析:
商家需要统计指定时间段内的营业额数据,并且以折线图的形式展示在商家的页面(搭配 Apache ECharts使用),而前端需要后端返回日期数据和营业额数据,以便用于前端的报表设计。而前端的请求参数主要是报表下面的日期,根据日期来展示营业额。
| 规则项 | 说明 |
|---|---|
| 统计对象 | 通常只统计已完成状态的订单(不包括待支付、已取消等) |
| 金额字段 | 订单的实付金额 (amount),即扣除优惠、配送费后的实际收入 |
| 时间维度 | 按天聚合:某一天内所有满足条件的订单金额总和 |
| 时间范围 | 通常支持近7天、近30天、本月、自定义区间等筛选 |
| 数据来源 | 订单表(orders) |
Controller层实现
java
java
// TurnoverController.java
package com.sky.controller.admin;
import com.sky.dto.TurnoverDTO;
import com.sky.result.Result;
import com.sky.service.TurnoverService;
import com.sky.vo.TurnoverVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.time.LocalDate;
@RestController
@RequestMapping("/admin/statistics")
@Api(tags = "数据统计接口")
@Slf4j
public class TurnoverController {
@Autowired
private TurnoverService turnoverService;
@GetMapping("/turnover")
@ApiOperation("营业额统计")
public Result<TurnoverVO> getTurnoverStatistics(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate start,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end) {
log.info("营业额统计参数:start={}, end={}", start, end);
TurnoverDTO turnoverDTO = new TurnoverDTO();
turnoverDTO.setStart(start);
turnoverDTO.setEnd(end);
TurnoverVO turnoverVO = turnoverService.getTurnoverStatistics(turnoverDTO);
return Result.success(turnoverVO);
}
}
Service层实现
java
// TurnoverServiceImpl.java
package com.sky.service.impl;
import com.sky.dto.TurnoverDTO;
import com.sky.mapper.OrdersMapper;
import com.sky.service.TurnoverService;
import com.sky.vo.TurnoverVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
public class TurnoverServiceImpl implements TurnoverService {
@Autowired
private OrdersMapper ordersMapper;
@Override
public TurnoverVO getTurnoverStatistics(TurnoverDTO turnoverDTO) {
// 1. 参数校验
LocalDate start = turnoverDTO.getStart();
LocalDate end = turnoverDTO.getEnd();
if (start == null || end == null) {
throw new IllegalArgumentException("开始日期和结束日期不能为空");
}
if (start.isAfter(end)) {
throw new IllegalArgumentException("开始日期不能晚于结束日期");
}
// 限制查询范围,防止性能问题(最多查询3个月)
if (start.until(end).getDays() > 90) {
throw new IllegalArgumentException("查询时间范围不能超过90天");
}
// 2. 查询数据库
List<Map<String, Object>> dbResult = ordersMapper.getTurnoverByDateRange(start, end);
// 3. 转换为Map,方便填充缺失日期
Map<LocalDate, BigDecimal> turnoverMap = dbResult.stream()
.collect(Collectors.toMap(
item -> ((java.sql.Date) item.get("date")).toLocalDate(),
item -> new BigDecimal(item.get("turnover").toString()),
(v1, v2) -> v1
));
// 4. 生成日期区间内的所有日期
List<LocalDate> allDates = new ArrayList<>();
LocalDate current = start;
while (!current.isAfter(end)) {
allDates.add(current);
current = current.plusDays(1);
}
// 5. 组装返回数据
List<String> dateStrs = new ArrayList<>();
List<BigDecimal> turnoverList = new ArrayList<>();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM-dd");
for (LocalDate date : allDates) {
dateStrs.add(date.format(formatter));
BigDecimal turnover = turnoverMap.getOrDefault(date, BigDecimal.ZERO);
turnoverList.add(turnover);
}
// 6. 日志记录
log.info("营业额统计完成:{} 至 {},共{}天,总营业额:{}",
start, end, allDates.size(),
turnoverList.stream().reduce(BigDecimal.ZERO, BigDecimal::add));
return TurnoverVO.builder()
.dates(dateStrs)
.turnoverList(turnoverList)
.build();
}
}
Mapper层实现
java
java
// OrdersMapper.java
package com.sky.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@Mapper
public interface OrdersMapper {
/**
* 按天统计营业额
* @param startDate 开始日期
* @param endDate 结束日期
* @return 统计结果列表
*/
@Select("SELECT DATE(order_time) as date, SUM(amount) as turnover " +
"FROM orders " +
"WHERE status = 3 " + // 已完成
"AND order_time BETWEEN #{startDate} AND #{endDate} " +
"GROUP BY DATE(order_time) " +
"ORDER BY date")
List<Map<String, Object>> getTurnoverByDateRange(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
/**
* 获取指定日期的营业额(用于定时任务统计)
*/
@Select("SELECT IFNULL(SUM(amount), 0) FROM orders " +
"WHERE status = 3 AND DATE(order_time) = #{date}")
BigDecimal getTurnoverByDate(@Param("date") LocalDate date);
}
常见坑点:
场景 :前端传入 2026-03-27,需要查询 2026-03-27 00:00:00 到 2026-03-27 23:59:59 的数据。
常见错误:直接使用前端传入的日期,导致边界数据丢失或重复统计。
MyBatis 中日期处理的坑
坑点1:BETWEEN 的边界陷阱
java
java
// ❌ 错误:直接使用前端传入的日期
@Select("SELECT * FROM orders WHERE order_time BETWEEN #{start} AND #{end}")
List<Order> getOrders(LocalDate start, LocalDate end);
// 前端传 start=2026-03-27, end=2026-03-27
// 实际 SQL: WHERE order_time BETWEEN '2026-03-27' AND '2026-03-27'
// 等价于: WHERE order_time >= '2026-03-27 00:00:00'
// AND order_time <= '2026-03-27 00:00:00'
// 结果:只查到 00:00:00 这一瞬间的数据,其他时间的数据查不到!
java
java
// ✅ 方案1:结束日期加1天,使用 < 而不是 <=
@Select("SELECT * FROM orders WHERE order_time >= #{start} AND order_time < #{endPlusOne}")
List<Order> getOrders(@Param("start") LocalDate start,
@Param("endPlusOne") LocalDate endPlusOne);
// 调用时
LocalDate start = turnoverDTO.getStart();
LocalDate endPlusOne = turnoverDTO.getEnd().plusDays(1);
java
// ✅ 方案2:手动拼接时分秒
@Select("SELECT * FROM orders WHERE order_time >= #{startDateTime} AND order_time <= #{endDateTime}")
List<Order> getOrders(@Param("startDateTime") LocalDateTime startDateTime,
@Param("endDateTime") LocalDateTime endDateTime);
// 调用时
LocalDateTime startDateTime = start.atTime(0, 0, 0);
LocalDateTime endDateTime = end.atTime(23, 59, 59);
坑点2:MyBatis 自动类型转换陷阱
java
java
// ❌ 错误:MyBatis 会自动将 LocalDate 转为 00:00:00
// 但结束日期可能被转为 00:00:00,导致当天数据查不到
@Select("SELECT * FROM orders WHERE DATE(order_time) BETWEEN #{start} AND #{end}")
List<Order> getOrders(LocalDate start, LocalDate end);
// 问题:
// 1. DATE(order_time) 函数导致索引失效
// 2. 当 start=end 时,BETWEEN 仍然有效,但性能差
java
// ✅ 正确:避免在 WHERE 条件中使用函数
@Select("SELECT * FROM orders WHERE order_time >= #{start} AND order_time < #{endPlusOne}")
List<Order> getOrders(@Param("start") LocalDate start,
@Param("endPlusOne") LocalDate endPlusOne);
坑点3:时间格式化的隐式转换
java
java
// ❌ 错误:字符串拼接 SQL,存在 SQL 注入风险
@Select("SELECT * FROM orders WHERE order_time >= '${start} 00:00:00'")
List<Order> getOrders(@Param("start") String start);
// ✅ 正确:使用参数绑定
@Select("SELECT * FROM orders WHERE order_time >= #{startDateTime}")
List<Order> getOrders(@Param("startDateTime") LocalDateTime startDateTime);
Service 层日期处理易错点
坑点4:LocalDate 转 LocalDateTime 的边界处理
java
java
// ❌ 错误:直接转换,没有考虑边界
LocalDateTime startDateTime = start.atStartOfDay(); // 00:00:00
LocalDateTime endDateTime = end.atTime(23, 59, 59); // 23:59:59
// 问题:如果数据库时间精度到毫秒,23:59:59.500 的数据查不到
java
// ✅ 正确:结束时间使用 23:59:59.999999
LocalDateTime startDateTime = start.atStartOfDay();
LocalDateTime endDateTime = end.atTime(23, 59, 59, 999999999);
// 或更简单:使用 plusDays(1) 和 < 比较
LocalDateTime startDateTime = start.atStartOfDay();
LocalDateTime endDateTime = end.plusDays(1).atStartOfDay();
// SQL: WHERE order_time >= #{startDateTime} AND order_time < #{endDateTime}
坑点5:使用 Date 类型导致时区问题
java
java
// ❌ 错误:使用旧的 Date 类型
Date startDate = Date.from(start.atStartOfDay(ZoneId.systemDefault()).toInstant());
// 问题:不同服务器时区导致时间偏移
java
// ✅ 正确:统一使用 LocalDateTime
LocalDateTime startDateTime = start.atStartOfDay();
LocalDateTime endDateTime = end.atTime(23, 59, 59);
坑点6:日期范围校验遗漏边界
java
java
// ❌ 错误:没有考虑跨月、跨年的边界
if (start.isAfter(end)) {
throw new IllegalArgumentException("开始日期不能晚于结束日期");
}
// 调用 plusDays(1) 可能跨月跨年,但没问题
LocalDateTime endDateTime = end.plusDays(1).atStartOfDay();
java
// ✅ 正确:增加业务限制
if (start == null || end == null) {
throw new IllegalArgumentException("日期不能为空");
}
if (start.isAfter(end)) {
throw new IllegalArgumentException("开始日期不能晚于结束日期");
}
if (ChronoUnit.DAYS.between(start, end) > 90) {
throw new IllegalArgumentException("查询时间范围不能超过90天");
}
营业额判空的各种写法
1. 基础写法(最安全)
java
java
// 方式1:传统 if-else
BigDecimal turnover = getTurnover();
if (turnover == null) {
turnover = BigDecimal.ZERO;
}
java
// 方式2:三目运算符
BigDecimal turnover = getTurnover();
turnover = turnover != null ? turnover : BigDecimal.ZERO;
2. 高级写法(更优雅)
java
java
// 方式3:Optional(推荐)
BigDecimal turnover = Optional.ofNullable(getTurnover()).orElse(BigDecimal.ZERO);
java
// 方式4:Optional + 默认值
BigDecimal turnover = Optional.ofNullable(getTurnover())
.orElse(BigDecimal.ZERO);
java
// 方式5:使用 Objects 工具类
BigDecimal turnover = Objects.requireNonNullElse(getTurnover(), BigDecimal.ZERO);
// 注意:requireNonNullElse 是 Java 9+ 才有的
3. 复杂场景:从 Map 中获取并判空
java
java
// ❌ 繁琐的写法
Map<String, Object> data = getData();
BigDecimal turnover = null;
if (data != null && data.containsKey("turnover") && data.get("turnover") != null) {
turnover = new BigDecimal(data.get("turnover").toString());
} else {
turnover = BigDecimal.ZERO;
}
// ✅ 优雅写法1:三目运算符链
BigDecimal turnover = data != null && data.get("turnover") != null
? new BigDecimal(data.get("turnover").toString())
: BigDecimal.ZERO;
// ✅ 优雅写法2:Optional 链
BigDecimal turnover = Optional.ofNullable(data)
.map(map -> map.get("turnover"))
.map(obj -> new BigDecimal(obj.toString()))
.orElse(BigDecimal.ZERO);
实现思路详解:
接下来就是用户总的数量统计和新增用户统计,核心流程都是一样的,值得注意的是在Service层的操作,对新手来说不是很友好,我们在这里大体的总结一下具体实现流程,缕一缕思路
1.首先,我们需要用一个集合datelist存放前端传过来的日期,前端传过来的是begin和end,但我们需要存放这个范围区间的所有日期,因此需要使用while循环来遍历,把值存入集合。
2.然后呢,我们需要再创建两个集合,用来封装返回给前端的两个数据,分别是总的用户数量,和每天新增的用户数量。
3.之后,既然要查询用户数量,就要使用sql语句进行查询,首先查询条件就是日期,但是前端传来的日期跟我们后端的日期格式不对应,先通过遍历把前端的格式转成后端数据库查询的格式,如果没转,出现的具体错误在上面已经说明了。
4.由此,我们把转换成正式格式的日期封装到我们新创建的一个map集合中,因为我们不会只查一天的吧,之后根据这个条件进行查询数据库。
5.封装在这个map中的时间就是where的查询条件,我们通过选择传入不同的值,就可以查询不同的需求。
注意:我们通常先查询总的用户数,再查询新增的用户,先查总用户(存量)是给新增用户(增量)当分母,算增长率用的,就像先知道锅里有多少饭,才知道今天新添了多少。
结语:
如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励,让我们一起进步!