后端开发日记⑨:运营统计、报表快照与定时任务

数据大屏关注实时运营,报表页关注一段时间内的趋势。它们都来自订单数据,但时间窗口、聚合方式和保存策略不同。本篇记录统计接口如何把数据库结果整理成前端可以直接使用的结构。
1. 大屏汇总使用统一时间边界
今日统计以系统时区的当天零点为开始、次日零点为结束,SQL 使用左闭右开区间:
[today 00:00:00, tomorrow 00:00:00)这种写法比拼接 23:59:59 更稳定,不会遗漏带毫秒的数据。订单总数、待处理、飞行中、完成、取消与异常数量都使用同一组边界,避免指标之间口径不一致。
2. 查询结果需要补零
数据库只返回有数据的小时。如果凌晨 3 点没有订单,原始结果里就没有这一项,前端折线会缩短或错位。
Service 先把原始结果转成 Map,再按完整时间序列逐点读取:
for (int hour = 0; hour < 24; hour++) { result.add(new Point(label(hour), rawMap.getOrDefault(key(hour), 0L)));}日报按小时、周报按天、月报按月都使用相同思路。时间轴完整性属于后端统计口径的一部分,不应让每个前端页面重复补齐。
3. 报表不仅是订单数量
AdminReportSnapshotStatsVo 组合了多组数据:
- 订单总量、完成率、平均配送时长;
- 超时率、异常率、取消率;
- 快递与外卖占比;
- 当前周期趋势;
- 配送失败原因分布;
- 与上一周期的变化率。
除法统一处理分母为零的情况,百分比保留两位小数。上一周期为零而当前不为零时,变化率按约定返回 100%,避免直接除零。
4. 实时统计与快照分开
日报、周报和月报接口可以实时查询当前周期;完整历史周期则由定时任务生成快照。快照把统计 VO 序列化为 JSON 存入 reportData,同时保存报表编号、类型、日期、标题与生成时间。
报表编号具有稳定格式:
DAY-20260825WEEK-20260817MONTH-202607保存前先按报表编号查询,存在就更新,不存在才新增。这样任务重复执行不会产生两份同周期报表。
5. 定时任务只做触发
ReportSnapshotTask 提供三个清晰入口:
saveDailyReportSnapshot();saveWeeklyReportSnapshot();saveMonthlyReportSnapshot();它们分别保存上一日、上一完整周和上一完整月的数据。Quartz 负责何时调用,统计时间范围、数据组装和幂等落库仍由 Service 完成。
订单模块也使用相同方式处理 15 分钟未支付超时:调度类只传入规则参数,业务判断留在订单 Service。
6. 时间与时区
统计使用 ZoneId.systemDefault() 把 LocalDateTime 转为数据库查询所需的 Date。部署环境的系统时区因此会影响报表边界,生产配置必须明确,不能默认所有机器都在同一时区。
周报以周一作为开始,月报以自然月第一天作为边界。只有把这些规则固定在后端,管理端切换周期时才不会产生各算各的情况。
7. 一条 SQL 同时计算多个状态
大屏概览没有为每个状态分别查询数据库,而是使用条件聚合:
select count(1) as totalOrders, coalesce(sum(case when order_status = 'PENDING_AUDIT' then 1 else 0 end), 0) as pendingAuditOrders, coalesce(sum(case when order_status = 'WAIT_DISPATCH' then 1 else 0 end), 0) as waitDispatchOrders, coalesce(sum(case when order_status = 'DELIVERING' then 1 else 0 end), 0) as flyingOrders, coalesce(sum(case when order_status = 'COMPLETED' then 1 else 0 end), 0) as completedOrdersfrom hy_delivery_orderwhere del_flag = '0' and create_time >= #{startTime} and create_time < #{endTime}case when 把满足条件的行记为 1,不满足记为 0,再使用 sum 得到各状态数量。coalesce 把没有数据时的 null 转为 0,减少 Java 端空值分支。
所有指标共用同一个时间过滤和删除标记,既减少数据库往返,也保证统计口径一致。create_time 上应建立与常用条件匹配的索引,否则订单量增加后每日全表扫描会拖慢大屏。
8. 今日和昨日如何在 Service 组装
ZoneId zoneId = ZoneId.systemDefault();LocalDate today = LocalDate.now(zoneId);Date todayStart = toDate(today, zoneId);Date tomorrowStart = toDate(today.plusDays(1), zoneId);Date yesterdayStart = toDate(today.minusDays(1), zoneId);
AdminOrderScreenCountVo todayCounts = normalize( mapper.selectOrderCounts(todayStart, tomorrowStart));AdminOrderScreenCountVo yesterdayCounts = normalize( mapper.selectOrderCounts(yesterdayStart, todayStart));三个边界点组成两个连续且不重叠的区间。使用 LocalDate.atStartOfDay(zoneId) 转为时间点,避免手动拼接日期字符串。
异常工单来自另一张表,因此单独统计后写入同一个 VO。这里再次体现 Service 的组装职责:Mapper 返回每张表擅长的原始统计,Service 形成页面需要的完整概览。
9. 环比计算怎样处理零分母
private BigDecimal calculateRate( Long todayCount, Long yesterdayCount) { long today = nullToZero(todayCount); long yesterday = nullToZero(yesterdayCount); if (yesterday == 0L) { return today == 0L ? BigDecimal.ZERO.setScale(2) : BigDecimal.valueOf(100).setScale(2); } return BigDecimal.valueOf(today - yesterday) .multiply(BigDecimal.valueOf(100)) .divide(BigDecimal.valueOf(yesterday), 2, RoundingMode.HALF_UP);}正常公式是 (今日 - 昨日) / 昨日 × 100%。昨日为零时数学上没有有限增长率,项目按展示约定返回 0 或 100。这个约定应该写进接口文档,避免前端误以为 100 表示严格数学结果。
使用 BigDecimal 而不是 double,并明确两位小数和四舍五入方式,保证不同页面得到一致结果。
10. 小时趋势为什么把分组值加一
SQL 使用 hour(create_time) 得到 0—23:
select hour(create_time) as hourValue, count(1) as orderCountfrom hy_delivery_orderwhere create_time >= #{startTime} and create_time < #{endTime}group by hour(create_time)Service 把数据库小时转换为区间终点:
int hourPoint = (raw.getHourValue() + 1) % 24;countMap.put(hourPoint, raw.getOrderCount());因此数据库的 0 点表示 0-1,在结果中使用 hourPoint = 1;23 点表示 23-24,使用 0 作为环形终点。buildPeriod(0) 特殊返回 23-24。
随后循环 0—23 补齐全部区间,没有订单的小时从 Map 取得默认值 0。前端拿到固定 24 个点,可以直接绘图。
11. 日、周、月三个时间窗口
// 当日LocalDateTime dayStart = today.atStartOfDay();LocalDateTime dayEnd = today.plusDays(1).atStartOfDay();
// 最近七个自然日(含今天)LocalDateTime weekStart = today.minusDays(6).atStartOfDay();LocalDateTime weekEnd = today.plusDays(1).atStartOfDay();
// 当前自然月LocalDate firstDay = today.withDayOfMonth(1);LocalDateTime monthStart = firstDay.atStartOfDay();LocalDateTime monthEnd = firstDay.plusMonths(1).atStartOfDay();“周报”在实时页面中表示最近七天,在定时快照中表示上一完整自然周。两者名称相似但时间口径不同,文章和接口文档都需要说清楚。
上一周期使用与当前周期等长且紧邻的区间,才能比较订单量、完成率和平均时长。
12. 快照幂等代码拆解
HyReportSnapshot existing = mapper .selectHyReportSnapshotByReportNo(reportNo);if (existing == null) { mapper.insertHyReportSnapshot(snapshot);} else { snapshot.setReportId(existing.getReportId()); snapshot.setRegionId(existing.getRegionId()); mapper.updateHyReportSnapshot(snapshot);}return mapper.selectHyReportSnapshotByReportNo(reportNo);reportNo 是周期的稳定业务键。Quartz 因重启、人工补执行或失败重试再次运行时,会覆盖同周期快照,而不是新增重复记录。
数据库最好为 report_no 建唯一索引,防止两个调度实例同时查询到不存在并各自插入。应用层的“先查后写”提供业务语义,唯一索引负责并发兜底。
13. 为什么快照保存 JSON
snapshot.setReportData(JSON.toJSONString(stats));JSON 可以完整保留当时的汇总、趋势、平台占比和失败原因,读取历史报表时不需要按当前数据库重新计算。即使后来订单被修正,历史快照仍表达生成当时的统计结果。
代价是 JSON 内部字段不便直接做数据库查询,VO 结构升级也要考虑旧数据兼容。稳定的常用指标可以同时保存为独立列,完整展示结构继续保留 JSON。
14. 定时任务的日志与监控
任务当前输出成功编号和处理数量。生产环境应使用日志框架替代 System.out.println,至少记录:
- 任务名称与调度时间;
- 实际统计区间;
- 生成或更新的报表编号;
- 处理数量和耗时;
- 失败异常与重试次数。
如果存在多实例部署,还要保证同一周期只有一个节点执行,或依靠数据库幂等与分布式锁共同控制。
15. 本篇小结
统计接口的难点是口径,而不是 SQL 数量。统一时间窗口、补齐空时间段、处理零分母、区分实时数据和历史快照,再让定时任务保持幂等,报表才能长期稳定地被前端和运营使用。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!





























