Day | 11 【苍穹外卖统计业务的实现:含详细思路分析】

🔥个人主页:北极的代码(欢迎来访)

🎬作者简介:java后端学习者

❄️个人专栏:苍穹外卖日记SSM框架深入JavaWeb

命运的结局尽可永在,不屈的挑战却不可须臾或缺!

前言:

前面我们完成了苍穹外卖的接单提醒和催单业务功能,具体实现逻辑,下一阶段,我们继续实现苍穹外卖的其他业务功能,主要是报表的可视化统计,自然而言的就要用到相应的工具,Apache ECharts就是不二之选,我们具体讲解,从了解这个工具到在实战中使用这个工具。

Apache ECharts是什么:

Apache ECharts 是一个基于 JavaScript 的开源可视化图表库。

简单来说,它是一套用于在网页上绘制交互式图表的工具。它由 Apache 软件基金会孵化并维护,前身是百度研发的 ECharts。

它的核心特点:

  1. 丰富的图表类型

    它几乎涵盖了所有常见的数据可视化图形,包括:折线图、柱状图、饼图、散点图、雷达图、地图(支持地理坐标)、热力图、关系图(如知识图谱)、树图、仪表盘等。

  2. 强大的交互性

    不仅只是静态图片,ECharts 生成的图表支持:

    • 鼠标悬停显示数据详情。

    • 图例切换(点击图例可以隐藏或显示某条数据线)。

    • 区域缩放(通过鼠标框选或滑动条查看数据细节)。

    • 动画效果

  3. 高度可定制

    你可以通过配置项(option)精确控制图表的颜色、字体、坐标轴、网格、提示框等几乎所有视觉元素,适配不同的设计风格。

  4. 跨平台与高性能

    它基于 HTML5 Canvas(画布)技术,在电脑端、手机端、平板端都能流畅运行。对于大数据量的展示(如数万甚至数十万个数据点),它通过增量渲染等技术依然能保持良好的流畅度。

  5. 使用简单

    你只需要在 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的查询条件,我们通过选择传入不同的值,就可以查询不同的需求。

注意:我们通常先查询总的用户数,再查询新增的用户,先查总用户(存量)是给新增用户(增量)当分母,算增长率用的,就像先知道锅里有多少饭,才知道今天新添了多少。

结语:

如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励,让我们一起进步!

相关推荐
xiaoye37087 小时前
Java 自动装箱 / 拆箱 原理详解
java·开发语言
YDS8298 小时前
黑马点评 —— 分布式锁详解加源码剖析
java·spring boot·redis·分布式
ZTLJQ8 小时前
数据的基石:Python中关系型数据库完全解析
开发语言·数据库·python
KD9 小时前
阿里云服务迁移实战(二)——网关迁移与前后端分离配置
后端
迷藏4949 小时前
**发散创新:基于 Rust的开源权限管理系统设计与实战**在现代软件架构中,**权限控制**早已不
java·开发语言·rust·开源
升鲜宝供应链及收银系统源代码服务9 小时前
《IntelliJ + Claude Code + Gemini + ChatGPT 实战配置手册升鲜宝》
java·前端·数据库·chatgpt·供应链系统·生鲜配送
daidaidaiyu9 小时前
Nacos实例一则及其源码环境搭建
java·spring
跟着珅聪学java9 小时前
js编写中文转unicode 教程
前端·javascript·数据库
小江的记录本9 小时前
【Redis】Redis全方位知识体系(附《Redis常用命令速查表(完整版)》)
java·数据库·redis·后端·python·spring·缓存