1. 报表不会是主角但永远是运营最后会抓住的那根稻草做完苍穹外卖的数据统计模块之后我最直接的一个感受是页面上那些折线图、占比图看着是挺炫但真正让业务方或者说让验收的人觉得这系统能用了的反而是不起眼的Excel报表导出。这个感受不是我一个人的。你回想一下身边任何一套管理系统——不管后台做得再多花哨的图表到了月底、到了对账、到了复盘的时候第一反应仍然是给我导个Excel。原因很简单Excel是可以通过聊天工具直接发出去的是可以一个人打开慢慢看的是能存档留底的是财务、运营、老板都能无障碍打开的通用语言。图表解决看的问题Excel解决用的问题。苍穹外卖-数据统计-Excel报表这个模块任务描述看着一句话就完了把统计结果导出成Excel文件。但真正动手拆解之后会发现这里面藏着三个层面问题统计口径怎么定、报表用什么方式生成、以及文件怎么稳定地交到用户手里。这篇文章我就把自己在这个模块上的完整思路和落地细节展开讲。适合两类人看一类是正在跟进外卖、电商这类交易系统项目做到数据统计阶段的朋友另一类是打算在简历里把这个模块写深一点想搞清楚导出Excel到底在导出什么的开发者。先说结论Excel报表模块的代码量可能只占整个数据统计功能的30%但它要踩的坑至少占一半。2. 统计口径先于代码这些数字从哪张表来决定了Excel有没有人认2.1 营业额、订单量、客单价三个数字一套算法很多新手做报表上来就写SQL。方向没错但缺了一步先搞清楚得分母的数和得分子的数到底是哪些记录。在苍穹外卖这种外卖系统里核心数据都集中在订单相关表中。我按自己的理解把口径拆成三块。营业额统计周期内已支付订单的实付金额合计。注意两个关键词——已支付和实付。待支付状态的订单不能算进去因为用户可能不付已取消的订单也不能算因为钱没实际进账。实付金额要取订单表里的实付字段而不是商品总价字段。外卖有满减、有折扣总价和实付在复杂促销下会出现漂移报表要的是真实入账的钱。订单量统计周期内有效订单数。这里的有效在教学项目里通常定义为已支付或已完成状态而不是全部下单记录。因为用户下单不付款、或者付款后又取消这些都属于无效流量对运营判断没有意义。SQL里做条件过滤的时候把状态字段写清楚。客单价营业额除以订单量。这个数通常不下数据库而是在Service层用两个统计结果动态算。除零问题要处理当天没有任何有效订单时客单价直接显示0.00而不是抛异常。对应到SQL我的核心思路是-- 营业额聚合已支付、未取消订单的实付金额 SELECT COALESCE(SUM(amount_paid), 0) FROM orders WHERE status IN (PAID, DELIVERING, COMPLETED) AND order_time #{beginTime} AND order_time #{endTime}; -- 订单量统计同一周期内的有效订单数 SELECT COUNT(*) FROM orders WHERE status IN (PAID, DELIVERING, COMPLETED) AND order_time #{beginTime} AND order_time #{endTime};之所以用和而不是BETWEEN是为了避免边界问题。BETWEEN是闭区间如果查询条件是某一天那23:59:59这个时间点之后的订单可能因为数据库时间精度问题被漏掉或者被重复算。用左闭右开区间配合下一分钟的起始时间最稳妥。2.2 菜品销量排名明细表才是主角Top10菜品销量是这个报表里最容易想当然的部分。有人会直接从订单主表找菜品字段——但外卖系统的订单是主从结构一个订单对应多条菜品明细主表里根本没有菜品信息。正确的位置是订单明细表也就是记录每个订单里点了哪些菜、各多少份的那张表。聚合思路SELECT dish_name, SUM(quantity) AS total_quantity, SUM(amount) AS total_amount FROM order_detail WHERE order_id IN ( SELECT id FROM orders WHERE status IN (PAID, DELIVERING, COMPLETED) AND order_time #{beginTime} AND order_time #{endTime} ) GROUP BY dish_name ORDER BY total_quantity DESC LIMIT 10;这里注意一点明细表里的金额是菜品项的金额不是订单实付金额。如果报表要展示Top10菜品贡献了多少营业额这里直接用明细金额聚合口径上没问题但如果要和总营业额对账就会发现明细金额之和可能大于订单实付金额因为满减优惠只在订单头扣没有按比例摊到明细里。遇到这种对不齐的情况不要慌这不是你代码写错了而是业务上优惠分摊本身就有一套复杂规则。教学项目里通常不要求摊分统计分析场景各用各的口径完全合理。2.3 时间范围用户只选了日期但SQL要的是秒级时间前端做日期选择拿到的通常是一个年月日字符串比如2024-12-01。你如果拿这个字符串直接拼进SQL去比较order_time大概率会出问题——因为order_time是带时分秒的datetime而2024-12-01会被解析成2024-12-01 00:00:00等于只查了当天零点那一刹那的数据。正确做法是在Controller层收下日期字符串后立刻转成LocalDate然后在Service层把它扩成当天的起始和结束时刻LocalDateTime beginTime localDate.atStartOfDay(); LocalDateTime endTime localDate.plusDays(1).atStartOfDay();endTime用下一天的零点配合SQL的比较刚好覆盖一整天。这个思路也是做日报、周报、月报的统一套路外层永远传左闭右开的区间。3. 模板渲染还是纯代码画表POI两种打法我都试了一遍3.1 为什么我选了Apache POI而不是EasyExcel网上做Excel导出呼声最高的一直是EasyExcel因为它封装度高、内存占用小。但在这个模块里我最后还是用了Apache POI。理由有两条第一项目的核心场景是单日运营概况报表数据量撑死了几百行。这个量级下POI的XSSFWorkbook对应xlsx格式完全是舒适区内存压力可以忽略。第二POI的API虽然啰嗦但它把Excel的每个元素都暴露得清清楚楚——单元格、样式、合并区域、列宽全部可控。用EasyExcel写这类简单报表当然更快但对理解Excel文件模型的帮助有限而且一旦遇到模板合并单元格、跨行样式这些需求EasyExcel的注解式写法反而约束更大。如果你拿到的报表需求是要导出几万行以上明细数据那我建议用EasyExcel的异步写或者POI的SXSSFWorkbook流式写。但对苍穹外卖这种教学级报表POI原生API完全足够代码写起来也直观。3.2 模板填充方案样式和代码解耦的正确姿势纯代码生成Excel最大的问题是样式代码会淹没业务代码。想象一下你要给标题加粗、给表头加底色、给金额列设两位小数格式、给整张表加边框——这些操作如果全部散落在业务代码里一个报表方法能写到两三百行而且换套配色就得改代码。我采用的做法是准备一份设计好的xlsx模板文件放在项目resources目录下代码只负责填充数据。模板长这样用Excel随便画能画表格就会第1行大标题XX外卖平台营业额统计报表合并A到E列居中字号16加粗第2行周期说明合并单元格比如统计周期2024-12-01 至 2024-12-01第3行表头分别是日期营业额订单量客单价排名前五菜品第4行往下留空等代码填数代码里这样读模板ClassPathResource resource new ClassPathResource(excel/report_template.xlsx); InputStream is resource.getInputStream(); XSSFWorkbook workbook new XSSFWorkbook(is);这一步解决了一个大难题样式维护不再需要改代码。运营想加一列退款金额直接在模板里加一列表头、画好样式发给开发者代码只需要把对应数据填到对应的列坐标就行。从项目协作的角度看这比让开发天天用代码调样式颜色要靠谱一个量级。3.3 纯代码兜底模板文件丢了怎么办模板方案有个隐性风险模板文件是静态资源上线打包进jar包之后万一哪天有人改了resources目录、模板文件名变了代码读取不到模板整个导出功能就挂了。所以我在代码里留了兜底逻辑模板读不到的时候自动切到纯代码方式用一套内置的默认样式现场画表。虽然丑了点但至少功能不瘫。XSSFWorkbook workbook; try { ClassPathResource resource new ClassPathResource(excel/report_template.xlsx); try (InputStream is resource.getInputStream()) { workbook new XSSFWorkbook(is); } } catch (IOException e) { log.warn(报表模板读取失败切换为代码生成模式, e); workbook createWorkbookByCode(); }实际项目里这种优雅降级不一定用得上但它在面对演示环境和生产环境资源差异时能帮你省掉一次半夜接到报警电话的机会。4. 从导出请求到浏览器下载一条完整链路的逐段拆解4.1 写Excel一个不那么性感的循环不管数据是从哪张表查出来的最后的落点都是同一个动作把结果集写到Excel的行和列里。POI的写入逻辑比较机械但有几个点很容易忽略。我以店员端日报为例先说怎么把每天的营业额、订单量、客单价写成一行Sheet sheet workbook.getSheetAt(0); int startRow 3; // 模板数据从第4行开始 CellStyle cellStyle workbook.createCellStyle(); cellStyle.setDataFormat(workbook.createDataFormat().getFormat(yyyy-MM-dd)); for (ReportRow data : dataList) { Row row sheet.createRow(startRow); Cell dateCell row.createCell(0); dateCell.setCellValue(data.getReportDate()); dateCell.setCellStyle(dateStyle); // 注意日期一定要套日期格式 row.createCell(1).setCellValue(data.getTurnover()); row.createCell(2).setCellValue(data.getValidOrderCount()); row.createCell(3).setCellValue(data.getAvgUnitPrice()); }这里最容易被忽视的是日期格式。如果你只setCellValue(LocalDate)而不设置日期格式Excel默认可能显示成一串数字比如46000这种日期序列值。用户看到那串数字根本反应不过来是哪天。所以日期列必须单独创建一个带格式的CellStyle。金额列同理要设置#,##0.00的格式否则可能显示成纯数字。4.2 下载文件的中文名一个URL编码能坑哭人的地方文件写好了怎么把它交给浏览器这一步最经典的坑就是response头的设置。直接上我当时排掉坑之后的最终版本response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); String fileName URLEncoder.encode(运营数据报表.xlsx, UTF-8) .replaceAll(\\, %20); response.setHeader(Content-Disposition, attachment;filename*utf-8 fileName);Content-Type必须是xlsx对应的MIME类型而不是application/octet-stream。后者虽然也能触发下载但可能让文件被浏览器当成未知类型的二进制数据双击打不开。中文文件名必须URL编码而且要用filename*utf-8这种RFC 5987写法。很多老教程只写filenamexxx.xlsx中文文件名直接放进去在部分浏览器上会乱码。最后把workbook写出去的方式也有讲究。try (OutputStream os response.getOutputStream()) { workbook.write(os); os.flush(); }如果你只调了workbook.write()忘了flush在小数据量下可能没问题但在网络环境差的场景里可能发生文件写入不完整的情况用户下载下来就是损坏的。把流操作放进try-with-resources写完自动关是最省心的做法。4.3 下载之后临时文件到底要不要清理如果你用的是SXSSFWorkbook流式写Excel适合大数据量它会往磁盘写临时文件。很多人写完就忘系统跑几个月之后磁盘被临时文件塞满才被运维发现。用完workbook.dispose()能释放SXSSF的临时文件。但如果你和我一样用的是XSSFWorkbook读模板的方式对象全在内存里JVM回收就解了不用特意处理。这里没有天然的标准答案核心是搞清楚你用的是哪套Workbook实现对应的生命周期管理手法完全不同。4.4 控制器完整逻辑三天数据循环填表苍穹外卖里这个报表Controller做的还不只是导出当前日期数据它还支持选近30天近7天这种批量周期。批量周期的处理方式是在Service层把时间段拆成天粒度逐天生成一行数据统一塞进列表再一次性填入Excel。好处是用户在Excel里能看到每天的独立数值而不是一行汇总。代码结构大致是GetMapping(/export) public void exportReport(RequestParam(date) String dateStr, HttpServletResponse response) throws IOException { LocalDate date LocalDate.parse(dateStr); ListDailyReportRow rows dashboardService.buildDailyRows(date); ExcelExporter exporter new ExcelExporter(); try (Workbook workbook exporter.generateReport(rows)) { ExcelResponseWriter.write(workbook, response); } }Controller层越薄越好。查数、组装、口径计算全部往下沉到Service和工具类Controller只负责接收参数和把文件交出去。这样后面想加一个定时生成日报发送邮件的功能Service层的东西可以直接复用。5. 凌晨自动跑数定时任务把日报变成全自动5.1 数据统计报表的自动版是怎么设计的用户手动点导出是解决临时想看的需求。但真正的运营场景里每天自动生成一份前一天的日报然后通过某种方式推给相关人比手动导出频繁得多。苍穹外卖这个模块在教学要求里通常没有强制定时任务但加一个并不复杂而且非常能体现你对业务的理解。我的做法是用Spring自带的Scheduled注解Component Slf4j public class ReportScheduledTask { Scheduled(cron 0 30 1 * * ?) // 每天凌晨1点30分执行 public void generateDailyReport() { LocalDate yesterday LocalDate.now().minusDays(1); ListDailyReportRow rows dashboardService.buildDailyRows(yesterday); ExcelExporter exporter new ExcelExporter(); try (Workbook workbook exporter.generateReport(rows)) { File destFile new File(reportDir, 日报_ yesterday .xlsx); // 省略目录创建和文件写入 } } }cron表达式里0 30 1 * * ?表示每天1点30分触发。选凌晨的原因是业务低峰即使查询稍微慢一点也不会影响在线用户。5.2 幂等千万不要一天生成两份日报定时任务最怕什么重复执行。项目部署没有加分布式锁、运维手动重启了服务、或者cron配置错了都可能让同一个日期的报表生成两次。最简单可靠的做法是生成之前先检查目标文件存不存在存在就跳过File reportFile new File(reportDir, 日报_ yesterday .xlsx); if (reportFile.exists()) { log.info(报表已存在跳过生成: {}, reportFile.getName()); return; }这种方式在单机部署下足够用了。如果将来部署到多实例可以把文件是否存在改成数据库里有没有这一天的报表生成记录并给日期字段建唯一索引效果等同。所谓幂等不一定要引入多复杂的技术朴素方案往往最稳。5.3 没数据也要生成空报表比没有报表更专业定日报最容易被忽略的边界是如果某天系统故障、或刚好是全平台暂停营业数据库里一条有效订单都没有报表还要不要生成我的答案是要。而且必须生成一份零值报表——营业额0.00订单量0客单价0.00Top10菜品为空。因为对运营来说今天没有数据和今天系统崩了没有生成报表是两回事。前者说明业务停摆或统计无异常后者会引发一场不必要的排查。实现方式查询结果为空的List直接让它进入写入逻辑。模板第3行往下的数据行全部留空但表头、标题、周期说明都在。文件是完整的只是内容为零。用户打开一看就明白是零数据而不是打不开。6. 报表上线的真实翻车现场金额精度、OOM、和我最后悔没早点知道的三件事6.1 金额字段的分与元差出一套血泪做报表的大忌是金额单位不统一。外卖系统的数据库设计常见有两种流派一种用decimal存元另一种用int存分。苍穹外卖里订单实付金额是decimal元但如果你以后碰到用分存储的项目报表导出的金额必须除以100转成元才能给用户看。这种分转元的逻辑千万别改数据库字段而要在查询层或组装层做一次转换。统一用一个工具方法public static BigDecimal fenToYuan(Integer fen) { if (fen null) { return BigDecimal.ZERO; } return BigDecimal.valueOf(fen).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); }趁报表模块早点把这个转换收口后面所有报表比如退款报表、结算报表都可以复用避免到处写/100然后某天有人忘了除报表数字直接放大一百倍。6.2 大数据量导出的OOM别等内存炸了才想起SXSSFExcel报表的数据量不是永远停留在教学项目那几百行。真实场景里查近一年所有订单明细几十万行并不夸张。用XSSFWorkbook硬扛数据都留在内存里很容易OOM。正确的做法是提前判断数据规模。量级小用XSSFWorkbook功能完整、可写样式量级大用SXSSFWorkbook滑动窗口写常驻内存只保留最近N行其余刷到磁盘临时文件。if (rows.size() THRESHOLD) { workbook new SXSSFWorkbook(); } else { workbook new XSSFWorkbook(); }SXSSFWorkbook用完之后一定要调dispose()清理临时文件不然磁盘会被慢慢塞满。这个我在前面提过但值得再强调一次因为它排查起来真的很隐蔽。6.3 模板文件的classpath路径打包后读不到是你没搞对路径模板文件放在src/main/resources/excel/下本地IDE跑的时候ClassPathResource(excel/report_template.xlsx)肯定能读到。但打成jar包部署之后resources目录会跟着进jar包ClassPathResource依然能读。真正会出问题的是另一种写法有人在本地用绝对路径FileInputStream(new File(src/main/resources/excel/...))本地没问题到服务器上立刻报FileNotFound。这不是玄学是路径理解错误。只要是通过Resources读取的classpath资源就必须用classpath方式访问别用文件系统路径。6.4 报表的重复代码终于长了记性样式工具类值得单独抽写第一版导出代码的时候我直接把单元格样式逻辑散落在报表Service里。等第二个报表需求过来比如新增退款统计报表发现复制粘贴占了大量篇幅而且改一个边框颜色要全局搜替换。后来我把样式抽成一个独立的ExcelStyleHelper里面定义好了标题样式、表头样式、数据样式、金额样式、日期样式等常量所有报表共用。新报表只要helper.createTitleCell(row, 0, 标题)一行就能完成原来五六行的样式代码。这段重构花费的半小时在后面每一个新报表需求里都能省回来。报表这种需求不会只来一次早抽样式工具类早享受。做Excel报表你可能会觉得它不如做一个推荐算法、做一个高并发抢单系统那么有技术含量。但报表恰恰是能体现出是否真的理解业务数据的地方——口径对不对、边界全不全、文件稳不稳每一个细节都会被人真实地使用和检验。如果你正在做苍穹外卖的数据统计模块别把这个功能当成普通的CRUD跳过它是少数几个能让你的项目完整度从能跑变成真好用的模块。