首页 / 资讯中心 / 文章详情

苍穹外卖day11:统计报表与Excel导出的核心实现与避坑指南

苍穹外卖day11:统计报表与Excel导出的核心实现与避坑指南 ★ FEATURED ARTICLE
如果你也正在做一个前后端分离的外卖项目那“苍穹外卖--day11”这个节点一定不陌生。到了第11天业务闭环基本已经打通员工登录、分类菜品、套餐管理、购物车、订单流转这些模块都有了接下来要面对的就不再是单纯的增删改查而是一堆“算钱、算人、算订单”的统计报表还有让人又爱又恨的Excel导出。说白了就是从“能不能跑”过渡到“好不好看、准不准、扛不扛造”的阶段。这篇文章把day11里最核心的几件事拆开聊一遍统计口径怎么定、定时任务怎么落、报表接口怎么写、后端导出Excel怎么搞以及我在这个阶段踩过的一堆坑。无论你是自己练手做的毕业设计还是公司里真的在搭外卖管理后台看完至少能少走三天的弯路。1. day11在项目里的真实定位功能收尾报表时代开启1.1 为什么这一天不是继续堆业务功能如果只看功能列表day11很容易被误以为只是“再加几个查询接口”。但真正接手项目的人会发现这一天是整个项目从“可用”到“好用”的分水岭。前面做的员工管理、菜品管理、订单管理本质上是把数据正确入库而从数据统计开始需求方关心的变成了“最近一个月营业额是多少”“哪些菜卖得最好”这些问题的共同点是要跨表、要聚合、要按时间切片还要处理各种各样的边界情况。苍穹外卖做到第11天的时候订单、用户、菜品这几张核心表里的数据已经积累起来了。没有了数据统计就是空中楼阁但有了数据之后统计模块对查询效率和代码组织的要求又远高于普通列表接口。所以这天的任务量看起来不大难度却一点不低。1.2 一张小票揭开的需求全景我们可以把外卖平台里每天发生的一笔订单拆成一条完整的数据链路顾客在用户端选菜、提交订单系统生成订单主表和订单明细表支付成功后订单状态流转商家端接单、出餐、送达顾客确认完成。等到第11天最直观的需求就是把这条链路上所有环节的数字拉出来给老板看。所以day11的核心任务通常包含四块营业额统计按日期区间统计已完成的订单金额和订单数用户统计统计新增用户数、用户总量最好能按天画出趋势订单统计统计有效订单数、订单完成率、平均客单价销量排名Top10按菜品维度统计销量最高的前十个菜。这四块并不是拍脑袋想出来的它对应的是管理者最关心的经营四问赚了多少、来了多少新客、订单成没成、什么东西好卖。明白了这个背景后面写代码时就不会只看SQL怎么查而是会下意识地检查口径是否合理。1.3 初始方案为什么不能用“实时查表”第一次做统计模块的人最容易掉进的坑是前端要一个数后端就现写一条select把订单表从头到尾聚合一遍。数据量小的时候没问题数据量一旦上来或者查询区间跨了好几个月这种写法会把数据库拖垮也会让接口的响应时间变得忽快忽慢。day11里更适合的做法是“统计结果缓存 必要的时候实时计算”。这个思路类似于超市收银不会每次顾客问“今天卖了多少”的时候才去把所有小票重新加一遍而是每天晚上闭店之后把当天的账算好白天直接看汇总数字。落到系统里就是用定时任务在凌晨把昨天的统计结果算好存入缓存或者独立的汇总表用户白天打开报表页面时接口直接返回已经算好的数据个别需要实时刷新的维度再单独查库。这样一个“先离线汇总、再按需实时”的二级方案既保证了高峰时段的体验又避免了动态大查询对核心订单库的冲击。具体的落地细节下一节展开说。2. 统计口径设计算得出来的数字未必是老板要的数字2.1 先把“营业收入”的定义定死写代码之前我把整个模块的口令向产品和运营同事重新对了一遍。最典型的坑在“营业额”三个字上到底是订单实付金额的总和还是扣除退款后的净收入是包含了配送费还是只算菜品金额在苍穹外卖这种场景里我最终采用的是统计“已完成”状态订单的实付金额并且对退款单做了扣除。体现到订单表上就是先通过status字段过滤掉无效订单再用amount字段做sum。之所以不统计“待支付”或“已取消”的订单是因为这些订单没有给平台带来真实收入算进去只会让报表虚高。口径定好之后查询接口的核心SQL就非常清晰了。比如要查某一天到某一天的营业额大致是这样select date(create_time) as stat_date, sum(amount) as turnover, count(id) as order_count from orders where status 5 and create_time #{beginDate} and create_time DATE_ADD(#{endDate}, INTERVAL 1 DAY) group by date(create_time) order by stat_date;这里有个容易被忽略的细节结束日期用“小于第二天零点”而不是“小于等于当天23:59:59”。因为create_time如果带时分秒直接用很容易漏掉最后一秒内的订单。这个习惯建议从一开始就养成后面所有时间范围查询都能受益。2.2 用户统计里的“新增用户”是个相对概念用户统计比营业额统计更容易出问题因为“用户数”在不同业务语境下定义完全不同。day11里我同时做了两个指标一个是“今日新增用户”一个是“总用户数”。“今日新增用户”的实现依赖用户表里的创建时间字段select count(id) from user where create_time #{beginDate} and create_time DATE_ADD(#{endDate}, INTERVAL 1 DAY);“总用户数”则是固定口径下的全表count不需要时间条件。真正麻烦的是产品方可能会要求“按天展示新增用户曲线”这时候如果用一条分组查询硬怼会得到很多没有新增用户的天前端画图时就会出现断点。我的处理方式是把一整段日期的每一天都先在内存里生成一个日期序列再用查询结果去填充查不到的日期补0。这样前端拿到的数据永远是连续的时间轴折线图画出来才好看。2.3 订单统计和完成率为什么要分成两步算订单统计里最核心的指标是“有效订单数”“订单完成率”和“平均客单价”。有效订单我沿用“已完成”状态的订单完成率则是有效订单除以总订单数而不是除以某种中间状态。这里的关键在于完成率的分母必须提前定义。是用当日所有订单做分母还是只算支付成功的订单我最终选择的是“当日有效订单数 / 当日总订单数”因为对老板来说顾客下单了但最终没完成就是一笔损失应该被暴露出来。如果分母只算支付订单完成率永远是接近满分的数字反而失去了监控意义。平均客单价的算法相对简单直接拿有效订单金额总和除以有效订单数即可。由于涉及除法SQL里要注意decimal类型的使用避免两个整数相除直接截断为0。在Java侧接口返回时也建议用字符串或者BigDecimal序列化防止前端JavaScript丢精度。2.4 销量Top10看起来简单细节不能漏销量排名的SQL一开始我写得很随意以为就是按数量sum排序然后limit 10。真正对完需求才发现这里有两个坑第一排名按的是“份数”还是“销售额”老板可能两个都想看第二排序维度要不要区分口味、规格比如“大杯珍珠奶茶”和“中杯珍珠奶茶”算不算同一个菜品。苍穹外卖里菜品和订单明细的关系是一对多订单明细表里存着菜品id、菜品名称、份数、金额。做销量排名时我先按菜品id分组sum份数作为销量sum金额作为销售额然后order by销量desclimit 10。select od.dish_id, od.dish_name, sum(od.number) as sales, sum(od.amount) as amount from order_detail od inner join orders o on od.order_id o.id where o.status 5 and o.create_time #{beginDate} and o.create_time DATE_ADD(#{endDate}, INTERVAL 1 DAY) group by od.dish_id, od.dish_name order by sales desc limit 10;需要注意如果用dish_name做分组条件必须保证同一菜品在不同订单里的名称完全一致一旦运营在后台改了菜品名历史数据里的名称还是旧值榜单就会出现两个“同菜异名”的条目。所以更稳妥的做法是以dish_id为唯一分组键dish_name只是展示字段分组后取最新名称。这个细节我在第11天吃过亏后面在“问题清单”里会再提。3. 定时任务与缓存让报表在高峰期也能秒开3.1 凌晨结算任务的设计统计结果要是现算白天店里单量一大数据库必然遭殃。day11引入定时任务的做法是每天凌晨1点执行一次前一天的统计数据汇总把结果写入一张独立的统计表或者Redis缓存里。选用Spring自带的Scheduled注解就足够不需要引入额外任务调度框架。核心代码如下Component Slf4j public class ReportDataTask { Scheduled(cron 0 0 1 * * ?) public void generateYesterdayReport() { LocalDate yesterday LocalDate.now().minusDays(1); // 调用统计服务计算营业额、用户数、订单数、Top10 reportService.generateStatistic(yesterday); log.info(生成昨日统计完成日期: {}, yesterday); } }定时任务本身不难难的是保证幂等。假如任务跑了一半宕机了第二天重启后会不会重复执行我的处理是在统计任务里加了一个“日期是否已生成”的判断如果目标日期在结果表里已经有数据就先删除再重算保证同一份数据不会被叠加两次。3.2 缓存选型与更新策略统计结果我选择放在Redis中用日期作为key的一部分例如report:turnover:2025-01-01value直接放JSON字符串。这样管理后台查询时只要根据日期拼出key就能拿到结果不需要每次穿透到MySQL。更新策略上凌晨任务只负责写入“昨天”的key而白天如果运营修改了某些订单状态比如补录退款就需要在订单状态变更的地方主动删除对应日期的缓存强制下次查询时重算。这个“删除缓存而不是更新缓存”的做法主要是为了避免并发下两个线程同时写导致出现旧值盖新值的问题。简单说拿不准的时候就删key让查询侧去加载最安全。3.3 统计模块的前后端约定接口设计上我建议把日期范围作为统一入参返回结构按天展开。比如营业额统计接口的返回格式是这样{ dateList: [2025-01-01, 2025-01-02], turnoverList: [3200.50, 4150.75], orderCountList: [120, 153] }这种扁平结构是专门为ECharts这类图表库准备的。前端拿到后不需要二次加工直接拿两个数组分别对应x轴和y轴。实际联调中我发现很多前端同事希望后端返回的是“左对齐的数组结构”而不是“一个map里嵌套对象数组”因为前者可以直接setOption。所以接口设计不能只从后端方便出发还要考虑调用方的使用成本。3.4 大时间跨度的查询容错有的报表会支持“最近12个月”甚至“任意日期区间”这时候如果缓存key按天粒度存查询时就需要批量拼接key然后从Redis里一次性取出来。为了避免对Redis发起大量单点get我习惯用pipeline或者mget批量读取能在一次网络往返里拿到全部数据。如果某天的key不存在比如系统还没上线那天就用默认的零值补齐。这里要特别小心空指针JSON反序列化时如果value是null要主动转成“0”而不是让接下来的计算直接抛异常。这类问题在开发环境通常跑不出来但生产环境特别容易在月末、年底的大区间查询里爆发。4. Excel报表导出后端顺手还是前端顺便我选后端4.1 为什么导出要放后端而不是前端很多前端同事会习惯性认为页面上的表格已经有了导出Excel不就是把DOM里的数据复制一遍吗但实际业务里报表导出的数据量往往比表格大得多而且需要包含多个Sheet、汇总行、表头样式。放在前端处理浏览器内存很容易被撑爆导出来的格式也难以统一。所以day11的Excel导出我放在后端接口里。后端根据前端传的起止日期从统计接口里查出数据再通过工具类写入Excel文件流最后以附件形式返回给浏览器。用户点击“导出”按钮本质上是在下载一个后端生成好的.xlsx文件。4.2 导出接口的完整实现流程我用的是EasyExcel相比原生的Apache POI它在内存占用和数据量处理上更友好。一个最简单的导出动作分成三步查数据、写Excel、设置响应头返回。GetMapping(/export) public void export(HttpServletResponse response, RequestParam String begin, RequestParam String end) throws IOException { ListReportVO data reportService.queryRange(begin, end); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); String fileName URLEncoder.encode(营业额报表_ begin _ end, UTF-8); response.setHeader(Content-disposition, attachment;filename fileName .xlsx); EasyExcel.write(response.getOutputStream(), ReportVO.class) .sheet(营业额统计) .doWrite(data); }这里有两个细节值得说一下。一是响应头里的文件名必须URLEncoder处理否则浏览器端中文名会乱码二是文件名的后缀里我写的是.xlsx和Content-Type要保持一致不能出现“明明生成的是Excel下载下来却打不开”的情况。4.3 样式与表头的美化技巧如果只是一行一行的数据写出来领导可能会觉得“不够专业”。EasyExcel支持在注解里直接声明列名比如ExcelProperty(value 日期, index 0) private String date; ExcelProperty(value 营业额, index 1) private BigDecimal turnover;要注意的是ExcelProperty的value会直接成为表头所以字段的命名要尽量口语化别把turnover这种英文直接暴露给使用者。对于汇总行我选择在数据列表末尾额外追加一条固定记录再通过行合并样式让它显示在表格底部。这个方法不难但能让报表看起来完整很多。4.4 大数据量导出时的内存控制如果统计区间横跨一整年数据可能达到几千行甚至上万行这时候一次性把所有数据加载到内存再写Excel很容易导致接口卡顿。EasyExcel提供了分批读取和写入的能力真正实现方式是“数据分批查询逐批doWrite”。ExcelWriter writer EasyExcel.write(response.getOutputStream()).build(); WriteSheet sheet EasyExcel.writerSheet(统计数据).build(); int page 1; while (true) { ListReportVO pageData reportService.queryPage(begin, end, page, 500); if (pageData.isEmpty()) break; writer.write(pageData, sheet); page; } writer.finish();这种写法把内存压力从“全量”降到了“每批500条”在数据量大时体验差别非常明显。实际项目中我做过压测同样的数据量分批导出比一次性导出节省约70%的峰值内存接口超时率也低了很多。5. 常见问题与排查技巧实录5.1 金额精度丢失报表差出几分钱统计中最常见、也最容易被忽视的问题是金额类型。数据库字段如果用float或者double经过多次sum之后会出现0.01甚至0.1的误差。外卖场景每一笔金额都和钱相关一旦对不上账开发就变成了背锅侠。正确的做法是数据库金额字段用decimalJava实体里用BigDecimal传输给前端时统一转成字符串。尤其是做除法计算客单价时千万不要用double先除再转BigDecimal正确写法是BigDecimal avg amount.divide(count, 2, RoundingMode.HALF_UP);这里的scale2表示保留两位小数roundingMode用四舍五入。如果不写BigDecimal除法在除不尽时会直接抛ArithmeticException这个坑我在第一次上手时就踩了。5.2 统计结果差了八小时问题出在时区换了部署环境之后报表突然发现凌晨的数据被算到了前一天排查到最后往往是时区问题。JDBC连接串里没有配置serverTimezone或者配置的时区和服务器所在时区不一致会导致date(create_time)的分组边界和预期相差8小时。解决方式是在数据库连接串上强制指定jdbc:mysql://localhost:3306/sky_take_out?serverTimezoneAsia/Shanghai同时在定时任务里明确使用LocalDate而不是Date避免JVM默认时区带来的歧义。如果排查时发现“只有这一天的数据不对”先去看看是不是跨了零点别第一时间怀疑SQL写错了。5.3 定时任务偶发重复执行统计被翻倍Spring的Scheduled默认是单线程执行的正常情况下不会重复。但项目如果部署了多个实例或者任务执行时间超过了下一个触发周期就可能出现重复执行。我在day11里见过最坑的情况是凌晨任务跑得慢到了凌晨1点还没结束结果1点整又触发了一次同一天的数据被算了两次。应对方法有两个第一在任务方法上加分布式锁保证同一时刻只有一个实例在跑统计第二在任务内部先判断目标日期是否已有结果已有则先删除再重算实现幂等。第二种方法成本低适合单体项目如果已经上了微服务架构建议把分布式锁加上。5.4 Excel下载后文件名乱码或打不开导出功能上线后测试反馈“文件名全是乱码”“用WPS打开正常用Excel打开提示文件损坏”。前者是响应头中文名没有URLEncoder导致后者通常是Content-Type和文件扩展名不匹配或者生成的Excel文件根本不是Excel格式而是错误堆栈信息。排查这类问题最快的办法是用Postman直接请求导出接口把返回内容保存成.xlsx文件然后用文本编辑器打开看文件头。真正的xlsx文件开头是PK字样如果看到的是html或者一堆异常堆栈说明接口在写流之前已经报错了。找到了这个根因再往前翻日志就能很快定位。5.5 菜品改名导致榜单出现两个同样的菜这个坑我在第11天下午才真正意识到。运营在后台把“鱼香肉丝”改成了“鱼香肉丝大份”结果销量排行榜里出现了两条数据一条是历史数据里的旧名字一条是新名字。分组维度如果用dish_name这个问题无解。所以最终我调整了SQL只按dish_id分组再做一次子查询把最新的菜品名称带出来。菜品名称只是一个展示字段不应该参与分组条件。如果项目里没有独立的菜品表或者菜品表已经被物理删除还可以考虑在订单明细表里冗余菜品名称的同时额外冗余一个菜品编码把这个编码作为分组的稳定键。最后说句实在话写统计报表最容易让新手崩溃的一点是明明数据都在接口也能通但报表数据就是和线下Excel对不上。我在day11里反复折腾最后发现多数问题都不是SQL写错了而是口径对不上、时区没统一、缓存没清干净。把口径在动手前用中文写清楚把时间边界统一成“左闭右开”把每个数字的来源都能讲明白后面剩下的就只是编码工作量了。如果你也在做苍穹外卖第11天之后的内容建议先别急着堆功能找个下午把已经写完的统计逻辑全量打印一遍对着一周的假数据逐项核。这个动作看起来慢其实是在给后面所有管理侧功能打地基。地基稳了后面加工作台、加数据大屏、加导出历史都只是扩展而已。
阅读完成 · 觉得有帮助?
咨询建站