首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Day | 11 【苍穹外卖统计业务的实现:含详细思路分析】

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

作者头像
北极的代码
发布2026-04-22 16:29:07
发布2026-04-22 16:29:07
650
举报

前言:

前面我们完成了苍穹外卖的接单提醒和催单业务功能,具体实现逻辑,下一阶段,我们继续实现苍穹外卖的其他业务功能,主要是报表的可视化统计,自然而言的就要用到相应的工具,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层实现
代码语言:javascript
复制
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层实现
代码语言:javascript
复制
// 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层实现
代码语言:javascript
复制
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:002026-03-27 23:59:59 的数据。

常见错误:直接使用前端传入的日期,导致边界数据丢失或重复统计。


MyBatis 中日期处理的坑

坑点1:BETWEEN 的边界陷阱
代码语言:javascript
复制
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 这一瞬间的数据,其他时间的数据查不到!
代码语言:javascript
复制
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 自动类型转换陷阱
代码语言:javascript
复制
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:时间格式化的隐式转换
代码语言:javascript
复制
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 的边界处理
代码语言:javascript
复制
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 类型导致时区问题
代码语言:javascript
复制
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:日期范围校验遗漏边界
代码语言:javascript
复制
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. 基础写法(最安全)
代码语言:javascript
复制
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. 高级写法(更优雅)
代码语言:javascript
复制
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 中获取并判空
代码语言:javascript
复制
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的查询条件,我们通过选择传入不同的值,就可以查询不同的需求。

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

结语: 如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励,让我们一起进步!
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2026-04-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Apache ECharts是什么:
    • 它的核心特点:
    • 常见应用场景:
  • 营业额统计业务需求分析:
    • Controller层实现
    • Service层实现
    • Mapper层实现
    • 常见坑点:
    • 坑点1:BETWEEN 的边界陷阱
    • 坑点2:MyBatis 自动类型转换陷阱
    • 坑点3:时间格式化的隐式转换
    • Service 层日期处理易错点
    • 坑点4:LocalDate 转 LocalDateTime 的边界处理
    • 坑点5:使用 Date 类型导致时区问题
    • 坑点6:日期范围校验遗漏边界
  • 营业额判空的各种写法
    • 1. 基础写法(最安全)
    • 2. 高级写法(更优雅)
    • 3. 复杂场景:从 Map 中获取并判空
  • 实现思路详解:
    • 结语: 如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励,让我们一起进步!
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档