苍穹外卖【day11| 用户统计,订单统计,销量排名统计功能实现】

🌈个人主页 :一条泥憨鱼(欢迎各位大佬莅临)

❄️《数据结构》 ❄️《AI与Agent那些事》

❄️《从0开始学计算机网络》 ❄️《后端开发》

用户统计

需求分析和设计

代码开发

整个模块涉及 4 层(Controller → Service → Mapper → VO),按数据流向逐层解读。


一、VO 层:数据的"快递盒"

UserReportVO.java

java 复制代码
@Data       // Lombok:自动生成 getter / setter / toString / equals / hashCode
@Builder    // Lombok:自动生成建造者模式(UserReportVO.builder().xxx().build())
@NoArgsConstructor  // 无参构造
@AllArgsConstructor // 全参构造
public class UserReportVO implements Serializable {
代码 解读
17 private String dateList; 日期列表,用逗号拼接。如 "2022-01-01,2022-01-02,2022-01-03"
20 private String totalUserList; 每个日期对应的累计总用户数,逗号分隔
23 private String newUserList; 每个日期对应的当日新增用户数,逗号分隔

为什么用字符串而不是数组? 前端 ECharts 图表组件通常接收逗号分隔的字符串,前端 split(",") 一下就能用。


二、Mapper 层:和数据库打交道

UserMapper.java

java 复制代码
//根据动态条件统计用户数量
Integer countByMap(Map map);

传入一个 Map,根据里面有什么 key 来动态拼接 SQL。

UserMapper.xml

XML 复制代码
<select id="countByMap" resultType="java.lang.Integer">
    select count(id) from user       <!-- 统计 user 表的总行数 -->
    <where>
        <if test="begin !=null">
            and create_time &gt;#{begin}    <!-- 如果传了 begin,只统计在 begin 之后创建的用户 -->
        </if>
    </where>
</select>

核心逻辑:MyBatis 动态 SQL。

传入的 Map 内容 生成 SQL 含义
{end: "2022-01-01 23:59:59"} select count(id) from user 无条件,统计全部用户(总用户数)
{end: "2022-01-01 23:59:59", begin: "2022-01-01 00:00:00"} select count(id) from user where create_time > '2022-01-01 00:00:00' 统计当天新注册的用户(新增用户数)

三、Service 层:核心业务逻辑(重点)

ReportServiceImpl.java

第 67 行 --- 方法签名

java 复制代码
public UserReportVO getUserStatistics(LocalDate begin, LocalDate end) {

接收两个参数:统计的起始日期结束日期(只含日期,不含时间)。


第 68-74 行 --- 构建日期列表

java 复制代码
//存放从begin到end之间的每天对应的日期
List<LocalDate> dateList = new ArrayList<>();
dateList.add(begin);
while (!begin.equals(end)) {
    begin = begin.plusDays(1);   // begin 往后推一天
    dateList.add(begin);
}

执行过程 (假设 begin=1月1日, end=1月3日):

循环次数 begin 当前值 begin.plusDays(1) dateList
初始 1月1日 --- [1月1日]
第1轮 1月1日 ≠ 1月3日 变成 1月2日 [1月1日, 1月2日]
第2轮 1月2日 ≠ 1月3日 变成 1月3日 [1月1日, 1月2日, 1月3日]
第3轮 1月3日 = 1月3日 → 退出 --- ✅ 最终 3 个日期

⚠️ 副作用 :循环结束后 begin 被改成了 end,好在此后不再需要 begin


第 77-79 行 --- 准备两个收集容器

java 复制代码
//存放每天新增的用户数量
List<Integer> newUserList = new ArrayList<>();
//存放每天的总用户数量
List<Integer> totalUserList = new ArrayList<>();

两个列表和 dateList 一一对应(同一个索引位置代表同一天)。


第 81-96 行 --- 逐天统计(核心循环)

java 复制代码
for (LocalDate date : dateList) {
    // ↓ 把"日期"转成"日期+时间",用于 SQL 比较
    LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN);  // 如 2022-01-01 00:00:00
    LocalDateTime endTime   = LocalDateTime.of(date, LocalTime.MAX);  // 如 2022-01-01 23:59:59.999...
常量 含义
LocalTime.MIN 00:00:00 一天开始
LocalTime.MAX 23:59:59.999999999 一天结束
java 复制代码
    Map map = new HashMap();
    map.put("end", endTime);

    //总用户数量
    Integer totalUser = userMapper.countByMap(map);
此时 map 内容 SQL 条件 统计结果
{end: "2022-01-01 23:59:59"} begin<if test="begin !=null"> 不成立 截至 1月1日之前的所有用户(总用户数)
java 复制代码
    map.put("begin", beginTime);
    //新增用户数量
    Integer newUser = userMapper.countByMap(map);
此时 map 内容 SQL 条件 统计结果
{end: "2022-01-01 23:59:59", begin: "2022-01-01 00:00:00"} create_time > '2022-01-01 00:00:00' 1月1日当天新注册的用户

技巧 :复用同一个 Map,逐步加条件,避免创建多个 Map 对象。

java 复制代码
    totalUserList.add(totalUser);
    newUserList.add(newUser);
}

把每天的结果分别存入对应列表。


第 98-104 行 --- 封装返回结果

java 复制代码
//封装结果数据
return UserReportVO.
        builder()                                  // 启动建造者模式
        .dateList(StringUtils.join(dateList, ","))          // [1月1日,1月2日] → "2022-01-01,2022-01-02"
        .totalUserList(StringUtils.join(totalUserList, ",")) // [200, 210] → "200,210"
        .newUserList(StringUtils.join(newUserList, ","))     // [20, 21] → "20,21"
        .build();                                  // 建造完成,返回 UserReportVO 对象

StringUtils.join(list, ",") 是 Apache Commons Lang 的工具方法,把 List 用逗号拼成一个字符串。


四、Controller 层:对外暴露接口

ReportController.java

java 复制代码
@GetMapping("/userStatistics")
@ApiOperation("用户统计")
public Result<UserReportVO> userStatistics(
        @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
        @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end
) {
    log.info("用户数据统计:{},{}", begin, end);
    return Result.success(reportService.getUserStatistics(begin, end));
}
说明
@GetMapping("/userStatistics") 响应 GET /admin/report/userStatistics?begin=2022-01-01&end=2022-01-31
@DateTimeFormat(pattern = "yyyy-MM-dd") 把 URL 中的字符串 "2022-01-01" 自动转为 LocalDate
Result.success(...) 用统一响应格式 {"code":1, "msg":"success", "data":{...}} 包裹返回

五、完整数据流图

复制代码
前端请求
  │  GET /admin/report/userStatistics?begin=2022-01-01&end=2022-01-03
  ▼
ReportController.userStatistics()
  │  解析参数 → LocalDate begin=1月1日, end=1月3日
  ▼
ReportServiceImpl.getUserStatistics()
  │
  ├─ ① 构建日期列表: [1月1日, 1月2日, 1月3日]
  │
  ├─ ② 逐天循环:
  │   ├─ 设 end=当天结尾 → countByMap({end}) → 总用户数(截止当天)
  │   └─ 加 begin=当天开始 → countByMap({end,begin}) → 新增用户数(当天注册)
  │       → user 表: WHERE create_time > begin
  │
  └─ ③ 建造 UserReportVO:
        dateList      = "2022-01-01,2022-01-02,2022-01-03"
        totalUserList = "200,210,220"
        newUserList   = "20,21,10"
  │
  ▼
Controller 包装为 Result.success() 返回 JSON

订单统计

需求分析和设计

代码开发


一、VO 层:返回数据模型

OrderReportVO.java

字段 类型 含义
16 dateList String 日期列表,逗号拼接,如 "2022-01-01,2022-01-02"
19 orderCountList String 每日订单总数 ,逗号拼接,如 "260,215"
22 validOrderCountList String 每日有效订单数 ,逗号拼接,如 "200,180"
25 totalOrderCount Integer 整个时间区间内的订单总数(一个数字)
28 validOrderCount Integer 整个时间区间内的有效订单数(一个数字)
31 orderCompletionRate Double 订单完成率 = 有效订单 / 订单总数

六字段分为两组:前三个存每日明细 (给折线图用),后三个存汇总数据(给饼图/指标卡用)。


二、Mapper 层:countByMap SQL

OrderMapper.xml

XML 复制代码
<select id="countByMap" resultType="java.lang.Integer">
    select count(id) from orders           -- 统计 orders 表的行数
    <where>
        <if test="begin != null">
            and order_time &gt; #{begin}    -- 下单时间 > begin
        </if>
        <if test="end != null">
            and order_time &lt; #{end}      -- 下单时间 < end
        </if>
        <if test="status != null">
            and status = #{status}          -- 订单状态筛选
        </if>
    </where>
</select>

和用户统计的关键区别 :这里多了 status 条件。传入不同 status 参数,统计不同含义:

传入参数 SQL 等价 含义
status=null count(id) WHERE order_time BETWEEN begin AND end 当天全部订单
status=5 count(id) WHERE order_time BETWEEN begin AND end AND status=5 当天已完成订单(有效订单)

三、Service 层:核心业务逻辑

3.1 主方法:getOrderStatistics

ReportServiceImpl.java

第 107 行 --- 方法签名
java 复制代码
public OrderReportVO getOrderStatistics(LocalDate begin, LocalDate end) {

第 109-114 行 --- 构建日期列表
java 复制代码
//存放从begin到end之间的每天对应的日期
List<LocalDate> dateList = new ArrayList<>();
dateList.add(begin);
while (!begin.equals(end)) {
    begin = begin.plusDays(1);   // begin 自身后移一天
    dateList.add(begin);
}

这和用户统计中完全一样的逻辑,生成 [1号, 2号, 3号, ..., N号]


第 117-118 行 --- 准备两个容器
java 复制代码
//存放每天的订单总数
List<Integer> orderCountList = new ArrayList<>();
//存放每天的有效订单数
List<Integer> validOrderCountList = new ArrayList<>();

两个列表与 dateList 索引一一对应。


第 121-130 行 --- 逐天查询(核心循环)
java 复制代码
for (LocalDate date : dateList) {
    LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN);  // 当天 00:00:00
    LocalDateTime endTime   = LocalDateTime.of(date, LocalTime.MAX);  // 当天 23:59:59

    Integer orderCount = getOrderCount(beginTime, endTime, null);          // ← 全部订单
    Integer validOrderCount = getOrderCount(beginTime, endTime, Orders.COMPLETED); // ← 仅已完成订单

    orderCountList.add(orderCount);
    validOrderCountList.add(validOrderCount);
}

关键技巧 :两次调用 getOrderCount,唯一区别是第三个参数:

调用 status 参数 统计对象
getOrderCount(begin, end, null) null 不管状态,当天所有订单
getOrderCount(begin, end, Orders.COMPLETED) 5 只统计已完成的订单

第 132-136 行 --- 汇总计算
java 复制代码
Integer totalOrderCount = orderCountList.stream().reduce(Integer::sum).get();
Integer validOrderCount = validOrderCountList.stream().reduce(Integer::sum).get();
代码 含义 示例输入 结果
stream().reduce(Integer::sum) 对列表所有元素求和 [100, 120, 80] 300
.get() Optional 中取出值 --- ---

第 138-141 行 --- 计算完成率
java 复制代码
Double orderCompletionRate = 0.0;               // 默认 0,避免除零
if (totalOrderCount != 0) {
    orderCompletionRate = validOrderCount.doubleValue() / totalOrderCount;
}
场景 计算 结果
总订单 300,有效 240 240.0 / 300 0.8
总订单 0 不进入 if 0.0(安全默认值)

第 143-153 行 --- 封装返回
java 复制代码
return OrderReportVO.builder()
        .dateList(StringUtils.join(dateList, ","))                     // [1号,2号] → "2022-01-01,2022-01-02"
        .orderCountList(StringUtils.join(orderCountList, ","))         // [100,120] → "100,120"
        .validOrderCountList(StringUtils.join(validOrderCountList, ",")) // [80,90] → "80,90"
        .totalOrderCount(totalOrderCount)                              // 300
        .validOrderCount(validOrderCount)                              // 240
        .orderCompletionRate(orderCompletionRate)                      // 0.8
        .build();

3.2 辅助方法:getOrderCount

ReportServiceImpl.java#L158-L165

java 复制代码
private Integer getOrderCount(LocalDateTime begin, LocalDateTime end, Integer status) {
    Map map = new HashMap();
    map.put("begin", begin);
    map.put("end", end);
    map.put("status", status);

    Integer count = orderMapper.countByMap(map);
    return count;
}
代码 解读
158 private Integer getOrderCount(...) private:仅供本类内部调用;第三个参数 status 可为 null
159 Map map = new HashMap(); 用 Map 传参,MyBatis 的 parameterType="map" 风格
160-162 map.put("begin/end/status", ...) 把三个参数放入 Map
164 orderMapper.countByMap(map) 调用 MyBatis,触发动态 SQL,返回 int
165 return count; 返回给调用方

四、动态 SQL 行为对照表

以 2022-01-01 这天为例(begin=2022-01-01 00:00, end=2022-01-01 23:59):

调用 Map 内容 生成 SQL 结果
getOrderCount(begin, end, null) {begin, end, status=null} count(id) FROM orders WHERE order_time > begin AND order_time < end 当天全部订单数
getOrderCount(begin, end, 5) {begin, end, status=5} count(id) FROM orders WHERE order_time > begin AND order_time < end AND status=5 当天有效订单数

五、完整数据流图

复制代码
GET /admin/report/ordersStatistics?begin=2022-01-01&end=2022-01-03
  │
  ▼
getOrderStatistics(1月1日, 1月3日)
  │
  ├─ ① dateList = [1月1日, 1月2日, 1月3日]
  │
  ├─ ② 逐天循环 ──┬── getOrderCount(当天, null)     → orderCountList      [100, 120, 80]
  │                └── getOrderCount(当天, COMPLETED) → validOrderCountList [ 80,  90, 60]
  │
  ├─ ③ totalOrderCount  = 100+120+80  = 300
  │    validOrderCount   = 80+90+60    = 230
  │    completionRate    = 230.0 / 300 = 0.767
  │
  └─ ④ OrderReportVO {
        dateList           = "2022-01-01,2022-01-02,2022-01-03"
        orderCountList     = "100,120,80"
        validOrderCountList= "80,90,60"
        totalOrderCount    = 300
        validOrderCount    = 230
        orderCompletionRate= 0.767
      }

六、与用户统计的对比

维度 用户统计 订单统计
复用 Map 策略 同一个 Map 逐步加条件(先只放 end,再加 begin) 同一天调两次 Mapper,传不同 status
Mapper 调用次数 每天 1 次(复用 Map) 每天 2 次(订单总数 + 有效订单)
有无辅助方法 无,直接在循环中写 getOrderCount 封装复用
汇总计算 无(VO 里只存每日明细) 有(totalOrderCount / completionRate)

销量排名TOP10

需求分析和设计

代码开发

一、涉及的文件速览

文件 角色
SalesTop10ReportVO.java 返回给前端的数据模型
GoodsSalesDTO.java Mapper 返回的中间传输对象
OrderMapper.java 定义 getSalesTop10 方法签名
OrderMapper.xml 销量 Top10 的 SQL 查询
ReportServiceImpl.java 核心业务逻辑

二、VO 层 + DTO 层

SalesTop10ReportVO

java 复制代码
private String nameList;    // "鱼香肉丝,宫保鸡丁,水煮鱼"
private String numberList;  // "260,215,200"

只有两个字段,分别存商品名称和对应销量,都用逗号拼接。

GoodsSalesDTO --- 中间传输对象

java 复制代码
private String name;     // 单个商品名称
private Integer number;  // 单个商品销量

SQL 查询结果中每一行映射为一个 GoodsSalesDTO,再在 Service 层转换为 SalesTop10ReportVO

为什么分两层? DTO 在 Mapper→Service 间传输(一条记录一个对象),VO 在 Service→Controller→前端传输(整个结果一个对象)。关注点分离。


三、Mapper 层:核心 SQL

OrderMapper.java

java 复制代码
List<GoodsSalesDTO> getSalesTop10(LocalDateTime begin, LocalDateTime end);

返回一个 List,每条 GoodsSalesDTO 代表一个商品的名称 + 销量。

OrderMapper.xml

sql 复制代码
select od.name, sum(od.number) number       -- ① 查商品名 + 销量求和
from order_detail od, orders o              -- ② 订单明细表 + 订单主表,隐式内连接
where od.order_id = o.id                    -- ③ 关联条件:明细属于哪个订单
  and o.status = 5                          -- ④ 只看已完成的订单
  and o.order_time > #{begin}               -- ⑤ 时间范围开始
  and o.order_time < #{end}                 -- ⑥ 时间范围结束
group by od.name                            -- ⑦ 按商品名分组
order by number desc                        -- ⑧ 按销量降序
limit 0,10                                  -- ⑨ 只取前 10 条

逐句解读:

SQL 片段 作用
order_detail od, orders o 旧式逗号连接写法,等价于 INNER JOIN orders o ON ...
od.order_id = o.id 通过 order_detail 的外键 order_id 关联 orders 的主键 id
o.status = 5 只统计已完成订单,排除取消/退款等无效订单
sum(od.number) number 是购买数量,按商品名分组后求和
group by od.name 把相同菜名的购买数量累加在一起
order by number desc 销量从高到低排序
limit 0,10 MySQL 分页语法,0 是 offset,10 是取的行数

为什么是 o.status = 5 回顾 Orders.java:COMPLETED = 5,只有已完成的订单销量才有意义。


四、Service 层:逐行解读

ReportServiceImpl.java#L170-L185

第 170 行 --- 方法签名

java 复制代码
public SalesTop10ReportVO getSalesTop10(LocalDate begin, LocalDate end) {

和前面的用户统计、订单统计一样,接收日期范围。


第 171-172 行 --- 日期转时间

java 复制代码
LocalDateTime beginTime = LocalDateTime.of(begin, LocalTime.MIN);  // start: 00:00:00
LocalDateTime endTime   = LocalDateTime.of(end, LocalTime.MAX);  // end:   23:59:59

第 174 行 --- 调用 Mapper

java 复制代码
List<GoodsSalesDTO> salesTop10 = orderMapper.getSalesTop10(beginTime, endTime);

一次 SQL 查询,返回 List<GoodsSalesDTO>,最多 10 条:

复制代码
[
  GoodsSalesDTO{name="鱼香肉丝", number=260},
  GoodsSalesDTO{name="宫保鸡丁", number=215},
  GoodsSalesDTO{name="水煮鱼",   number=200},
  ...
]

第 175-176 行 --- 提取商品名称列表

java 复制代码
List<String> names = salesTop10.stream()               // 开启流
        .map(GoodsSalesDTO::getName)                   // 每个 DTO → 取出 name
        .collect(Collectors.toList());                 // 收集为 List<String>
String nameList = StringUtils.join(names, ",");        // ["鱼香肉丝","宫保鸡丁"] → "鱼香肉丝,宫保鸡丁"
步骤 输入 输出
stream() List<GoodsSalesDTO> Stream<GoodsSalesDTO>
map(GoodsSalesDTO::getName) 每个 DTO 提取 .name 字段
collect(toList()) Stream<String> List<String> = ["鱼香肉丝", "宫保鸡丁", "水煮鱼"]
StringUtils.join(..., ",") List<String> "鱼香肉丝,宫保鸡丁,水煮鱼"

第 178-179 行 --- 提取销量列表

java 复制代码
List<Integer> numbers = salesTop10.stream()             // 开启流
        .map(GoodsSalesDTO::getNumber)                  // 每个 DTO → 取出 number
        .collect(Collectors.toList());                  // 收集为 List<Integer>
String numberList = StringUtils.join(numbers, ",");     // [260,215,200] → "260,215,200"

与上面完全对称的逻辑,只是提取的是 number 字段。


第 181-184 行 --- 封装返回

java 复制代码
return SalesTop10ReportVO
        .builder()
        .nameList(nameList)        // "鱼香肉丝,宫保鸡丁,水煮鱼"
        .numberList(numberList)    // "260,215,200"
        .build();

五、与前两个统计的对比

维度 用户统计 订单统计 销量排名 Top10
循环 ✅ 逐天循环 ✅ 逐天循环 ❌ 一次 SQL 搞定
Mapper 调用次数 每天 1 次 每天 2 次 总共 1 次
SQL 复杂度 简单 count 简单 count 多表联查 + GROUP BY + ORDER BY + LIMIT
数据转换 直接拼字符串 直接拼字符串 Stream → map → collect → join
有汇总计算 ✅(total/rate)

销量排名之所以不需要循环,是因为 GROUP BY + LIMIT 10 本身就是聚合操作,MySQL 一次性算完。


六、完整数据流图

复制代码
GET /admin/report/top10?begin=2022-01-01&end=2022-01-31
  │
  ▼
getSalesTop10(1月1日, 1月31日)
  │
  ├─ ① beginTime = 1月1日 00:00:00, endTime = 1月31日 23:59:59
  │
  ├─ ② orderMapper.getSalesTop10(beginTime, endTime)
  │     │
  │     └─ SQL:
  │        SELECT od.name, SUM(od.number)
  │        FROM order_detail od, orders o
  │        WHERE od.order_id = o.id
  │          AND o.status = 5
  │          AND o.order_time > '2022-01-01 00:00'
  │          AND o.order_time < '2022-01-31 23:59'
  │        GROUP BY od.name
  │        ORDER BY number DESC
  │        LIMIT 10
  │     │
  │     └─ 返回: [ {name:鱼香肉丝,number:260}, {name:宫保鸡丁,number:215}, ... ]
  │
  ├─ ③ Stream 提取 names  → "鱼香肉丝,宫保鸡丁,水煮鱼,..."
  │    Stream 提取 numbers → "260,215,200,..."
  │
  └─ ④ SalesTop10ReportVO { nameList: "...", numberList: "..." }

至此,day11的代码开发全部实现完毕。

相关推荐
huaweichenai1 小时前
spring boot操作PDF
java·spring boot·pdf
她的男孩1 小时前
缓存明明命中了却报 ClassCastException:拆完多级缓存控制面,我挖出 5 个静默失效的坑
java·后端·架构
T_Apollo1 小时前
2.5 Java 8.0 版本新增特性和类
java
孙启超1 小时前
【AI开发之Rust】第 13 课:async/await 与 tokio 异步运行时
开发语言·后端·rust
SimonKing1 小时前
SpringBoot 集成 SSE 实现服务端推送或可代替Websocket
java·后端·程序员
字节探索1 小时前
Redis 只会当缓存用?这 10 大实战场景,让你的系统快到飞起
redis·后端
不正经的码狗1 小时前
JAVA 、Eclipse 安装及配置 JAVA 项目(无需配置环境)
java·eclipse
xiaoqiMikko2 小时前
你升的是 jackson-databind,中招的是 jackson-core:一个超长 JSON key 就能吃掉 200MB 堆
java·安全
不正经的码狗2 小时前
Eclipse汉化(快速,推荐)
java·eclipse