欢迎光临
我们一直在努力

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

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

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

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

工作台

需求分析和设计

代码开发

一、Controller 层

WorkSpaceController.java

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

接口 1:今日营业数据

@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:三组概览

@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 — 今日营业数据怎么算出来

public BusinessDataVO getBusinessData(LocalDateTime begin, LocalDateTime end) {


第 54-56 行 — 构建查询条件

Map map = new HashMap();
map.put("begin", begin); // 今天 00:00:00
map.put("end", end); // 今天 23:59:59

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


第 59 行 — 总订单数

Integer totalOrderCount = orderMapper.countByMap(map);

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

SELECT COUNT(id) FROM orders
WHERE order_time > '今天00:00:00' AND order_time < '今天23:59:59'

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


第 61-64 行 — 营业额 + 有效订单数

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 行 — 计算完成率和客单价

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 行 — 新增用户数

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 行 — 封装返回

return BusinessDataVO.builder()
.turnover(turnover)
.validOrderCount(validOrderCount)
.orderCompletionRate(orderCompletionRate)
.unitPrice(unitPrice)
.newUsers(newUsers)
.build();


3.2 getOrderOverView — 订单概览

WorkspaceServiceImpl.java#L93-L126

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", 新值) 覆盖上一次。

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

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:

SELECT COUNT(id) FROM dish WHERE status = #{status}


3.4 getSetmealOverView — 套餐总览

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

SetmealMapper.xml countByMap:

SELECT COUNT(id) FROM setmeal WHERE status = #{status}


四、核心 Mapper SQL:countByMap 和 sumByMap

OrderMapper.xml

SQL用途首次出现
countByMap COUNT(id) 统计订单/用户/菜品/套餐数量
sumByMap SUM(amount) 统计营业额(工作台独有)

<!– 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

@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

方法签名:

public void exportBusinessData(HttpServletResponse response) {

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


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

LocalDate dateBegin = LocalDate.now().minusDays(30); // 30 天前
LocalDate dateEnd = LocalDate.now().minusDays(1); // 昨天

假设今天是dateBegindateEnd含义
2022-06-15 2022-05-16 2022-06-14 最近 30 天,不含今天(今天数据不完整)

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

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 行)

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 行)

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 行 有效订单 客单价

// 第 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 行)

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 列 = 新增用户
}

循环变量 idate写入行 7+i效果
0 2022-05-16 第 8 行 第 1 天的数据
1 2022-05-17 第 9 行 第 2 天的数据
… … … …
29 2022-06-14 第 37 行 第 30 天的数据

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

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天的代码开发已经全部结束。

赞(0)
未经允许不得转载:171主机测评 » 苍穹外卖【day12| 工作台,通过Apache POI导入/导出运营数据】
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址