苍穹外卖【day12| 工作台,通过Apache POI导入/导出运营数据】

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

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

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

工作台

需求分析和设计

代码开发

一、Controller 层

WorkSpaceController.java

@RequestMapping("/admin/workspace") 为该类所有接口统一前缀。

接口 1:今日营业数据

java 复制代码
@GetMapping("/businessData")
public Result<BusinessDataVO> businessData() {
    LocalDateTime begin = LocalDateTime.now().with(LocalTime.MIN);  // 今天 00:00
    LocalDateTime end   = LocalDateTime.now().with(LocalTime.MAX);  // 今天 23:59
    BusinessDataVO businessDataVO = workspaceService.getBusinessData(begin, end);
    return Result.success(businessDataVO);
}
行 代码 解读
42 LocalDateTime.now() 获取当前日期+时间 ,如 2022-06-15T14:30:00
42 .with(LocalTime.MIN) 把时间部分设为 00:00:00,保留日期不变
44 .with(LocalTime.MAX) 把时间部分设为 23:59:59.999...

为什么 Controller 层自己算时间? 因为工作台默认就是"今天"的数据,不需要前端传参。

接口 2-4:三组概览

java 复制代码
@GetMapping("/overviewOrders")     // 订单概览:待接单/待派送/已完成/已取消
@GetMapping("/overviewDishes")     // 菜品概览:启售数/停售数
@GetMapping("/overviewSetmeals")   // 套餐概览:启售数/停售数

这三个接口都 不需要参数,直接调用 Service 获取全量统计。


二、VO 层:4 个返回数据模型

2.1 BusinessDataVO --- 今日营业核心指标

字段 类型 含义
turnover Double 营业额(今天完成订单的总金额)
validOrderCount Integer 有效订单数(今天完成的订单数)
orderCompletionRate Double 订单完成率 = 有效 / 总订单
unitPrice Double 平均客单价 = 营业额 / 有效订单
newUsers Integer 今日新增用户数

2.2 OrderOverViewVO --- 订单状态分布

字段 含义
waitingOrders 待接单(status=2)
deliveredOrders 待派送(status=3)
completedOrders 已完成(status=5)
cancelledOrders 已取消(status=6)
allOrders 全部订单

2.3 DishOverViewVO & SetmealOverViewVO --- 菜品/套餐状态

两者结构完全一样:

字段 含义
sold 已启售数量(status=1)
discontinued 已停售数量(status=0)

三、Service 层(重心)

WorkspaceServiceImpl.java

3.1 getBusinessData --- 今日营业数据怎么算出来

java 复制代码
public BusinessDataVO getBusinessData(LocalDateTime begin, LocalDateTime end) {

第 54-56 行 --- 构建查询条件
java 复制代码
Map map = new HashMap();
map.put("begin", begin);  // 今天 00:00:00
map.put("end",   end);    // 今天 23:59:59

此时 Map 内容:{begin: ..., end: ...},还没放 status。


第 59 行 --- 总订单数
java 复制代码
Integer totalOrderCount = orderMapper.countByMap(map);

此时 map 里没有 status,触发 SQL:

sql 复制代码
SELECT COUNT(id) FROM orders
WHERE order_time > '今天00:00:00' AND order_time < '今天23:59:59'

统计今天所有创建的订单,不管什么状态。


第 61-64 行 --- 营业额 + 有效订单数
java 复制代码
map.put("status", Orders.COMPLETED);  // status = 5
//营业额
Double turnover = orderMapper.sumByMap(map);
turnover = turnover == null ? 0.0 : turnover;   // ← 防 null
//有效订单数
Integer validOrderCount = orderMapper.countByMap(map);
调用 SQL 效果
sumByMap(map) SELECT SUM(amount) FROM orders WHERE ... AND status=5 今天已完成订单的金额总和
countByMap(map) SELECT COUNT(id) FROM orders WHERE ... AND status=5 今天已完成订单的数量

为什么传入同一个 map? 因为 sumByMap 的 <if test="status != null"> 会生效,复用 Map 对象省内存。


第 68-74 行 --- 计算完成率和客单价
java 复制代码
Double unitPrice = 0.0;
Double orderCompletionRate = 0.0;
if (totalOrderCount != 0 && validOrderCount != 0) {
    orderCompletionRate = validOrderCount.doubleValue() / totalOrderCount;
    unitPrice = turnover / validOrderCount;
}
表达式 计算 示例
validOrderCount.doubleValue() / totalOrderCount 有效订单 ÷ 总订单 80 / 100 = 0.8
turnover / validOrderCount 营业额 ÷ 有效订单 5000.0 / 80 = 62.5

双重检查 != 0,确保分母不为零。


第 77 行 --- 新增用户数
java 复制代码
Integer newUsers = userMapper.countByMap(map);

⚠️ 这里 map 里有 status=5! 传到 userMapper.countByMap(map) 时,UserMapper.xml 里没有 <if test="status != null"> 条件,所以 status=5 被忽略,查询仍是全量新用户。但如果 UserMapper.xml 未来加了 status 判断就会出 bug。


第 79-85 行 --- 封装返回
java 复制代码
return BusinessDataVO.builder()
        .turnover(turnover)
        .validOrderCount(validOrderCount)
        .orderCompletionRate(orderCompletionRate)
        .unitPrice(unitPrice)
        .newUsers(newUsers)
        .build();

3.2 getOrderOverView --- 订单概览

WorkspaceServiceImpl.java#L93-L126

java 复制代码
Map map = new HashMap();
map.put("begin", LocalDateTime.now().with(LocalTime.MIN));  // 今天 00:00

这里只看今天的订单 ,因为 begin 有值而 end 没有,SQL 变成 order_time > 今天00:00,无上限。

然后逐状态查询 5 次:

第 N 行 map.put("status", ...) 查询结果 含义
98 TO_BE_CONFIRMED (2) waitingOrders 待接单
106 CONFIRMED (3) deliveredOrders 待派送
110 COMPLETED (5) completedOrders 已完成
114 CANCELLED (6) cancelledOrders 已取消
118 null allOrders 全部订单(去掉 status 条件)

核心技巧:同一个 Map 对象,复用修改 status,调 5 次 Mapper 。每次 map.put("status", 新值) 覆盖上一次。

java 复制代码
map.put("status", null);          // 把 status 设为 null
Integer allOrders = orderMapper.countByMap(map);
// SQL: WHERE order_time > 今天00:00(没有 status 条件)

3.3 getDishOverView --- 菜品总览

WorkspaceServiceImpl.java#L133-L146

java 复制代码
Map map = new HashMap();
map.put("status", StatusConstant.ENABLE);   // status = 1
Integer sold = dishMapper.countByMap(map);   // 启售菜品数

map.put("status", StatusConstant.DISABLE);  // status = 0
Integer discontinued = dishMapper.countByMap(map); // 停售菜品数

DishMapper.xml countByMap 的 SQL:

sql 复制代码
SELECT COUNT(id) FROM dish WHERE status = #{status}

3.4 getSetmealOverView --- 套餐总览

与菜品完全对称,只是调 setmealMapper.countByMap(map),查的是 setmeal 表。

SetmealMapper.xml countByMap:

sql 复制代码
SELECT COUNT(id) FROM setmeal WHERE status = #{status}

四、核心 Mapper SQL:countByMap 和 sumByMap

OrderMapper.xml

SQL 用途 首次出现
countByMap COUNT(id) 统计订单/用户/菜品/套餐数量
sumByMap SUM(amount) 统计营业额(工作台独有)
sql 复制代码
<!-- countByMap: 统计订单数 -->
select count(id) 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>

<!-- sumByMap: 统计营业额(工作台专用) -->
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>
Map 内容 countByMap 效果 sumByMap 效果
{begin, end} 今天总订单数 今天总营业额
{begin, end, status=5} 今天已完成订单数 今天已完成营业额

五、完整架构图

复制代码
┌──────────────────────────────────────────────────────┐
│  WorkSpaceController  /admin/workspace               │
│                                                      │
│  GET /businessData      → 今日营业数据               │
│  GET /overviewOrders    → 订单概览(按状态)          │
│  GET /overviewDishes    → 菜品概览(启售/停售)       │
│  GET /overviewSetmeals  → 套餐概览(启售/停售)       │
└──────┬───────────────────────────────────────────────┘
       │
       ▼
┌──────────────────────────────────────────────────────┐
│  WorkspaceServiceImpl                                │
│                                                      │
│  getBusinessData():                                  │
│    ① countByMap({begin,end})          → 总订单数     │
│    ② sumByMap({begin,end,status=5})   → 营业额       │
│    ③ countByMap({begin,end,status=5}) → 有效订单     │
│    ④ 计算:完成率、客单价                             │
│    ⑤ userMapper.countByMap({begin,end}) → 新增用户   │
│                                                      │
│  getOrderOverView():                                 │
│    逐状态调用 countByMap × 5                         │
│                                                      │
│  getDishOverView():                                  │
│    dishMapper.countByMap(status=1) → sold            │
│    dishMapper.countByMap(status=0) → discontinued    │
│                                                      │
│  getSetmealOverView(): (同上结构, 查 setmeal 表)     │
└──────┬───────────────────────────────────────────────┘
       │
       ▼
┌──────────────────────────────────────────────────────┐
│  Mapper 层                                           │
│  OrderMapper  ─→ sumByMap / countByMap               │
│  UserMapper   ─→ countByMap                          │
│  DishMapper   ─→ countByMap                          │
│  SetmealMapper──→ countByMap                         │
└──────────────────────────────────────────────────────┘

Apache POI

导出运营数据Excel报表

需求分析和设计

代码开发

导出运营数据报表模块 --- 逐行详解

涉及 4 个文件:

文件 角色
ReportController.java 接收请求、设响应头
ReportService.java 接口定义
ReportServiceImpl.java 核心逻辑
template/运营数据报表模板.xlsx Excel 模板文件

一、Controller 层:入口

ReportController.java#L74-L81

java 复制代码
@GetMapping("/export")                          // ①
public void export(HttpServletResponse response){  // ②
    response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");  // ③
    response.setHeader("Content-Disposition", "attachment; filename=report.xlsx");                 // ④
    reportService.exportBusinessData(response);  // ⑤
}
行 注解 逐行解读
① @GetMapping("/export") 完整路径:GET /admin/report/export。注意返回类型是 void ,不走 Result.success() 包裹,因为这是文件下载流
② HttpServletResponse response Spring 自动注入原生响应对象,用于控制 HTTP 响应
③ setContentType(...) 告诉浏览器:"我发的是 Excel 文件(.xlsx 格式)"
④ setHeader(...) 告诉浏览器:"这是一个附件下载 ,保存时默认文件名叫 report.xlsx"。没有这行,浏览器可能直接显示乱码而非弹出下载框
⑤ exportBusinessData(response) 把 response 传给 Service,让 Service 往里面写数据

return void 是什么道理? Spring MVC 检测到方法返回 void 且有 HttpServletResponse 参数时,不会走视图解析器,而是认为"你已经通过 response 自己处理了输出"。


二、Service 层:三步骤

ReportServiceImpl.java#L197-L258

方法签名:

java 复制代码
public void exportBusinessData(HttpServletResponse response) {

void --- 不需要返回值,数据直接写入 response 的输出流里。


步骤 1:确定时间范围(第 200-201 行)

java 复制代码
LocalDate dateBegin = LocalDate.now().minusDays(30);   // 30 天前
LocalDate dateEnd   = LocalDate.now().minusDays(1);     // 昨天
假设今天是 dateBegin dateEnd 含义
2022-06-15 2022-05-16 2022-06-14 最近 30 天,不含今天(今天数据不完整)

步骤 2:查 30 天汇总数据(第 203 行)

java 复制代码
BusinessDataVO businessDataVO = workspaceService.getBusinessData(
    LocalDateTime.of(dateBegin, LocalTime.MIN),   // 2022-05-16 00:00:00
    LocalDateTime.of(dateEnd,   LocalTime.MAX)    // 2022-06-14 23:59:59
);

调 workspaceService.getBusinessData(begin, end),返回这 30 天的整体汇总:

businessDataVO 字段 含义 示例值
turnover 30 天总营业额 150000.00
validOrderCount 30 天有效订单数 3200
orderCompletionRate 30 天订单完成率 0.85
unitPrice 30 天平均客单价 46.88
newUsers 30 天新增用户数 480

回忆 :getBusinessData 内部调用 orderMapper.sumByMap 算营业额、countByMap 算订单数。这里不再重述。


步骤 3:加载 Excel 模板(第 208 行)

java 复制代码
InputStream in = this.getClass()
        .getClassLoader()
        .getResourceAsStream("template/运营数据报表模板.xlsx");
代码片段 含义
this.getClass() 获得当前类的 Class 对象
.getClassLoader() 获得类加载器 ,负责从 classpath 找文件
getResourceAsStream("...") 从 resources/ 目录下读文件,返回一个输入流

实际路径:sky-server/src/main/resources/template/运营数据报表模板.xlsx

失败返回 null(文件不存在时)。


步骤 4:用 POI 打开模板(第 212-216 行)

java 复制代码
XSSFWorkbook excel = new XSSFWorkbook(in);    // ① 把输入流解析为 Excel 工作簿对象
XSSFSheet sheet = excel.getSheet("Sheet1");   // ② 获取第一个工作表
sheet.getRow(1).getCell(1).setCellValue(      // ③ 第 2 行、第 2 列,填入日期
    "时间:" + dateBegin + "至" + dateEnd
);
API 含义
XSSFWorkbook POI 提供的 .xlsx 工作簿对象,对应整个 Excel 文件
XSSFSheet 工作表,对应 Excel 底部的 Sheet 标签页
XSSFRow 一行
XSSFCell 一个格子

POI 的行列索引从 0 开始:

  • getRow(1) → Excel 第 2 行(第 1 行通常是表头)
  • getCell(1) → Excel 第 B 列(第 A 列是 0)

步骤 5:填入 30 天汇总数据(第 218-228 行)

模板设计如下(第 4 行 = getRow(3)):

列 A(0) B(1) C(2) D(3) E(4) F(5) G(6)
第 4 行 营业额 完成率 新增用户
第 5 行 有效订单 客单价
java 复制代码
// 第 4 行(汇总行)
XSSFRow row = sheet.getRow(3);
row.getCell(2).setCellValue(businessDataVO.getTurnover());            // C4 = 营业额
row.getCell(4).setCellValue(businessDataVO.getOrderCompletionRate()); // E4 = 订单完成率
row.getCell(6).setCellValue(businessDataVO.getNewUsers());            // G4 = 新增用户

// 第 5 行(汇总行)
row = sheet.getRow(4);
row.getCell(2).setCellValue(businessDataVO.getValidOrderCount());     // C5 = 有效订单
row.getCell(4).setCellValue(businessDataVO.getUnitPrice());           // E5 = 客单价

步骤 6:填入 30 天每日明细(第 231-243 行)

java 复制代码
for (int i = 0; i < 30; i++) {                          // 循环 30 天
    LocalDate date = dateBegin.plusDays(i);              // 第 i 天是哪一天

    // 查询这一天的营业数据
    BusinessDataVO businessData = workspaceService.getBusinessData(
        LocalDateTime.of(date, LocalTime.MIN),           // 当天 00:00:00
        LocalDateTime.of(date, LocalTime.MAX)            // 当天 23:59:59
    );

    row = sheet.getRow(7 + i);                           // 从第 8 行开始,每天一行
    row.getCell(1).setCellValue(date.toString());        // B 列 = 日期
    row.getCell(2).setCellValue(businessData.getTurnover());         // C 列 = 营业额
    row.getCell(3).setCellValue(businessData.getValidOrderCount());  // D 列 = 有效订单
    row.getCell(4).setCellValue(businessData.getOrderCompletionRate()); // E 列 = 完成率
    row.getCell(5).setCellValue(businessData.getUnitPrice());        // F 列 = 客单价
    row.getCell(6).setCellValue(businessData.getNewUsers());         // G 列 = 新增用户
}
循环变量 i date 写入行 7+i 效果
0 2022-05-16 第 8 行 第 1 天的数据
1 2022-05-17 第 9 行 第 2 天的数据
... ... ... ...
29 2022-06-14 第 37 行 第 30 天的数据

步骤 7:输出到浏览器(第 247-253 行)

java 复制代码
ServletOutputStream out = response.getOutputStream();  // ① 获取响应输出流

excel.write(out);   // ② 把 Excel 工作簿的二进制数据写入输出流
out.close();        // ③ 关闭输出流(强制刷出缓冲区)
excel.close();      // ④ 关闭工作簿(释放 POI 资源)
行 代码 含义
① response.getOutputStream() 拿到 HTTP 响应的字节输出流
② excel.write(out) POI 把整个 Excel 对象序列化成 .xlsx 二进制,写入流
③ out.close() 关闭流 → 浏览器开始接收文件
④ excel.close() 释放 Excel 对象占用的内存

三、模板文件结构一览

复制代码
运营数据报表模板.xlsx → Sheet1:

      A          B        C         D           E          F         G
1   ┌─────────┬────────┬─────────┬─────────┬──────────┬─────────┬──────────┐
    │(标题行)  │        │         │         │          │         │          │
2   │         │ 时间:  │ 2022-05-16至2022-06-14                        │
3   │         │        │         │         │          │         │          │
4   │         │        │ 150000  │         │  0.85    │         │   480    │  ← 汇总
5   │         │        │  3200   │         │  46.88   │         │          │  ← 汇总
6   │         │        │         │         │          │         │          │
7   │         │ 表头   │ 营业额  │ 有效订单 │ 完成率   │ 客单价  │ 新增用户 │  ← 列标题
8   │         │05-16   │ 5000    │  100    │  0.80    │  50.00  │   20     │  ← 第1天
9   │         │05-17   │ 4800    │   95    │  0.82    │  50.53  │   15     │  ← 第2天
... │         │ ...    │ ...     │  ...    │  ...     │  ...    │  ...     │
37  │         │06-14   │ 5200    │  110    │  0.85    │  47.27  │   18     │  ← 第30天
    └─────────┴────────┴─────────┴─────────┴──────────┴─────────┴──────────┘

四、与其他统计的区别

维度 营业额统计 订单统计 销量 Top10 导出报表
返回格式 JSON JSON JSON .xlsx 文件流
返回类型 Result<T> Result<T> Result<T> void(直接写流)
时间范围 前端传参 前端传参 前端传参 固定最近 30 天
数据来源 单一 Mapper 单一 Mapper 单一 Mapper 复用 WorkspaceService
循环 ✅ 逐天 ✅ 逐天 ❌ ✅ 逐天(30 次)
额外依赖 无 无 无 Apache POI

五、完整数据流图

复制代码
用户点击"导出"按钮
  │
  ▼
GET /admin/report/export
  │
  ▼
ReportController.export(response)
  ├─ ① 设 Content-Type = .xlsx
  ├─ ② 设 Content-Disposition = attachment
  └─ ③ → reportService.exportBusinessData(response)
            │
            ▼
ReportServiceImpl.exportBusinessData(response)
  │
  ├─ 步骤1: dateBegin = 30天前, dateEnd = 昨天
  │
  ├─ 步骤2: workspaceService.getBusinessData(30天)
  │          返回: BusinessDataVO { turnover, validOrderCount, ... }
  │
  ├─ 步骤3: 加载模板 → InputStream in = "template/运营数据报表模板.xlsx"
  │
  ├─ 步骤4: POI 打开模板 → XSSFWorkbook excel = new XSSFWorkbook(in)
  │          填标题行:第2行 ← 日期
  │
  ├─ 步骤5: 填汇总行(第4-5行)← businessDataVO 的 5 个字段
  │
  ├─ 步骤6: 循环 30 次,逐天查 workspaceService.getBusinessData(当天)
  │          填第 8-37 行 ← 每天的 5 个字段
  │
  └─ 步骤7: excel.write(response.getOutputStream())
             → 浏览器弹出下载框,保存为 report.xlsx

至此,第12天的代码开发已经全部结束。

相关推荐
合尘猫5 分钟前
AI 项目上线就停:一次事件循环阻塞的完整排查过程
人工智能·后端·python
Re_zero23 分钟前
一次 SSE 断流排查:PrintWriter 吞掉了我的 IOException
java·后端
1m5555555531 分钟前
RAG 工程实战(四):明明召回了正确知识,为什么最终 Top-K 还是漏了?
后端
我是人✓1 小时前
Java核心语法:作用域、循环与大数值处理
java·开发语言·后端
newerp1 小时前
GC Pacer 调优算法:GOGC、GOMEMLIMIT 与容器 OOM 防御
后端·程序员·go
邱杰4701 小时前
数智人事系统(二):一条命令起齐四个中间件
后端
newerp1 小时前
逃逸分析、栈分配与 sync.Pool 对象池
后端·程序员·go
upala1 小时前
【仓颉语言入门 · 第二篇 第 3 课】
后端
松就是我902981 小时前
3.7 万 Star 的「第二大脑」Khoj,为什么关掉了云服务
后端