做Java开发的基本都遇到过这种需求:产品经理丢过来一句“帮我拉一下今天的数据”“统计本周的销售额”“看下这个月的活跃用户”,听着很简单,不就是加个时间条件吗?真正动手写的时候才发现,难点根本不在SQL,而在“今天”“本周”“本月”这几个时间边界到底怎么精确算出来。早年间我用java.util.Date配合Calendar,那叫一个痛苦,又是set(Calendar.HOUR_OF_DAY, 0)又是set(Calendar.MINUTE, 0),少set一个字段,查出来的数据可能就差了整整一秒,线上问题排查半天,最后定位到是时间边界算错了。Java 8引入的LocalDateTime系列API把这件事简化了非常多,但我在很多项目里看到,大家拿着新API还是用老思路,甚至直接用23:59:59.999这种“闭区间”写法,给自己埋坑。这篇文章就围绕“LocalDateTime获取天、周、月的开始时间和结束时间”这个高频场景,把我这几年在项目里的实践方案、代码套路、踩坑经验一次性讲透。
1. 整体思路:为什么时间边界值得单独写一篇
1.1 老API的痛点:Date/Calendar的时代遗留问题
先说老的方案有多难用。Java 8之前,获取“今天开始”这种时间大概要写这么一堆代码:
Calendar calendar = Calendar.getInstance(); calendar.set(Calendar.HOUR_OF_DAY, 0); calendar.set(Calendar.MINUTE, 0); calendar.set(Calendar.SECOND, 0); calendar.set(Calendar.MILLISECOND, 0); Date startOfDay = calendar.getTime();这里有几个特别经典的坑。
第一,Calendar的月份从0开始计数,所以calendar.set(Calendar.MONTH, 5)你以为在设置5月,实际上是6月。这个反人类设定几乎坑过所有Java开发者。第二,SimpleDateFormat不是线程安全的,很多人图省事把它定义成static供全局使用,一旦高并发跑起来,会出现时间解析错乱、甚至直接抛NumberFormatException这类诡异问题。第三,Calendar是可变的,你在一个方法里set了某个字段,同一个Calendar对象在别的地方用的时候状态已经被改了,这种“隐蔽的共享状态变更”在复杂业务里特别难排查。
而Date本身更是简陋,本质上就是一个long型的毫秒时间戳包装,它能提供的能力只有new Date()获取当前时间、getTime()拿毫秒数,其他所有操作都得靠Calendar或者SimpleDateFormat配合完成。可以说,Java 8之前的时间日期操作,没有一套“一站式”的顺心API,这也是为什么很多人宁可写一堆工具类也不愿直接操作Date和Calendar。
1.2 LocalDateTime的设计哲学:不可变、链式、语义清晰
Java 8的时间API(JSR-310)参考了Joda-Time的设计,核心特点有三个,理解了这三个特点,你写代码的思路就会完全不一样。
第一是不可变性。LocalDateTime、LocalDate、LocalTime都是不可变对象,每一次“修改”操作都会返回一个新的实例,原来的对象不会被改变。这意味着它们天然线程安全,不需要用ThreadLocal去包一层,也不用担心多个线程同时修改同一个时间对象导致状态错乱。我之前在做并发任务调度的时候就踩过Calendar共享的坑,换成LocalDateTime之后这类问题彻底消失。
第二是链式调用。几乎所有修改时间的方法都会返回一个新对象,可以直接一路点下去写,阅读代码的时候就像在读一句自然语言。比如today.plusDays(1).with(TemporalAdjusters.firstDayOfMonth()),意思非常清楚:先把日期加一天,再调整到那个月的第一天。这种表达方式比Calendar那种“先new一个实例,再set字段,再操作”的写法要直接得多,也更容易发现逻辑错误。
第三是语义清晰。类名直接告诉你它代表什么:LocalDate只代表日期(年、月、日),LocalTime只代表时间(时、分、秒、纳秒),LocalDateTime代表日期加时间,Instant代表时间戳,ZoneId代表时区。各司其职,不会像Date那样什么都放进去但什么都不好用。你拿LocalDate去存数据库的date字段,拿LocalDateTime去存datetime字段,类型对应得很舒服。
1.3 时间边界的设计核心:从闭区间思维切换到右开区间
在实际查询场景里,“开始时间和结束时间”最大的学问不是怎么把时间取整,而是怎么定义区间的开闭。
很多人的第一反应是:开始时间用00:00:00,结束时间用23:59:59,或者干脆写成LocalTime.MAX。这种“闭区间”思维在数学上完全正确,但工程上问题很大。
第一个麻烦是精度不匹配。LocalTime.MAX的值是23:59:59.999999999,纳秒精度。但数据库里的datetime字段往往只精确到秒(datetime(0))或毫秒(datetime(3))。你把纳秒值传给datetime(0),数据库会做舍入,23:59:59.999999999很可能直接变成第二天的00:00:00。本来想查23:59:59之前的数据,结果把0点之后的数据也带出来了,精确查询直接出错。
第二个麻烦是语义含混。业务数据是动态生成的,你无法保证某条记录的创建时间不会落在23:59:59.500。如果查询条件是<= 23:59:59,那这条记录恰好被排除在外,而这本应是当天数据。这种边界漏数据的问题,线上排查起来非常隐蔽。
所以我在文章里的核心建议是:全面切换到“右开区间”思维。开始时间取周期的起点,结束时间取“下一个周期的起点”,SQL写法就是create_time >= ? AND create_time < ?。用Java代码表示就是:一天的范围是[当天00:00:00, 次日00:00:00),一周是[本周一00:00:00, 下周一00:00:00),一月是[当月1号00:00:00, 下月1号00:00:00)。这样不用关心毫秒和纳秒的精度问题,也永远不会漏掉边界数据。
2. 必备基础:LocalDateTime时间体系的前置知识
2.1 LocalDate、LocalTime、LocalDateTime三兄弟怎么分工
动手写代码之前,先把这几个核心类的关系理清楚。很多新手会问:既然LocalDateTime能表示完整的日期和时间,为什么还要有LocalDate和LocalTime?
这么设计是为了“按需取用”,减少类型上的迷惑。有的业务只需要日期,比如生日、节日、排班日期,用LocalDate就够了,和数据库的date字段正好对应。有的业务只需要时间,比如每天9:30开门,用LocalTime正好。完整的订单时间、操作日志、流水记录这些才用LocalDateTime。
它们之间的转换也极其简单,这里列几个我每天都在用的:
LocalDate date = LocalDate.now(); LocalTime time = LocalTime.now(); // LocalDate + LocalTime 拼成 LocalDateTime LocalDateTime dateTime = LocalDateTime.of(date, time); // 获取当前完整时间 LocalDateTime now = LocalDateTime.now(); // LocalDateTime 拆出日期和时间 LocalDate datePart = now.toLocalDate(); LocalTime timePart = now.toLocalTime(); // LocalDate + 当天零点 => LocalDateTime LocalDateTime startOfToday = LocalDate.now().atStartOfDay();这里特别要记住atStartOfDay()这个方法,它的作用是把一个LocalDate拼上“午夜零点”转成LocalDateTime。后面所有周期起点的计算都会用到它。它的底层等价于atTime(LocalTime.MIN),但语义更清晰,读代码的人一看就知道你是在拿“某一天的零点”。
还有一个实用细节:LocalDateTime.now()和LocalDate.now().atStartOfDay()的区别。前者是当前时刻的完整时间,比如下午3点24分55秒;后者是今天凌晨0点0分0秒。刚开始用这套API的同学容易搞混这两个概念,写周统计的时候如果用了前者,等于是把“当前时刻”当成“本周开始时间”,结果区间直接错乱。
2.2 TemporalAdjusters:日期调整器的正确打开方式
java.time.temporal.TemporalAdjusters(注意有个s,是工具类)是这套时间API里非常实用的一个类。一句话总结它的作用:帮你把任意一个日期调整到下一个符合你预期的日期,不需要自己写循环去判断星期几、不用关心这个月有几天。
我在实践中最常用的方法有这些:
| 方法 | 作用 |
|---|---|
| firstDayOfMonth() | 当月第一天 |
| lastDayOfMonth() | 当月最后一天 |
| firstDayOfNextMonth() | 下月第一天 |
| next(DayOfWeek.MONDAY) | 下一个周一,不包含当天 |
| nextOrSame(DayOfWeek.MONDAY) | 下一个周一,如果当天是周一则返回当天 |
| previous(DayOfWeek.MONDAY) | 上一个周一,不包含当天 |
| previousOrSame(DayOfWeek.MONDAY) | 上一个周一,如果当天是周一则返回当天 |
具体用法是:
LocalDate today = LocalDate.now(); LocalDate firstDayOfMonth = today.with(TemporalAdjusters.firstDayOfMonth()); LocalDate lastDayOfMonth = today.with(TemporalAdjusters.lastDayOfMonth()); LocalDate thisMonday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));注意,with(TemporalAdjusters.xxx())这种调用方式,第一个参数其实是一个TemporalAdjuster接口,它的返回值可以是一个新的日期,也可以是一个新的时间或者日期时间对象,取决于你调用它是拿什么类型去with。比如LocalDate.with(...)返回LocalDate,LocalDateTime.with(...)返回LocalDateTime,这个设计让同一个调整器能同时适配多种时间类型。
有一个容易产生歧义的地方:next(DayOfWeek.MONDAY)和nextOrSame(DayOfWeek.MONDAY)的差别。如果今天是周一,next(MONDAY)返回的是下周一,而nextOrSame(MONDAY)返回的是今天。如果你没有注意到这个语义,用错了会导致统计周期差出整整7天,这在报表里是非常严重的错误。
2.3 WeekFields:周的定义其实是个文化问题
周的天数边界和日、月不同,它没有全球统一的答案。国内习惯把周一作为一周的开始,ISO-8601标准也是这么约定的;但欧美很多地区习惯把周日作为一周的开始。哪怕同一个小团队里,不同业务线的统计口径也可能不一样。所以在计算“周”的边界时,一定要先明确“你们系统的周一是周几”。
WeekFields就是用来描述“这个系统里的一周是怎么定义”的。它接收两个参数:firstDayOfWeek表示一周的第一天,minimalDaysInFirstWeek表示一年中第一周最少占用的天数。
// ISO标准:周一为一周第一天,且每年第一周至少包含4天 WeekFields iso = WeekFields.ISO; // 自定义:周一为一周第一天,第一周至少包含1天 WeekFields custom = WeekFields.of(DayOfWeek.MONDAY, 1); // 美国习惯:周日为一周第一天 WeekFields us = WeekFields.of(DayOfWeek.SUNDAY, 1); LocalDate today = LocalDate.now(); // 本周第一天(周几取决于WeekFields的定义) LocalDate firstDay = today.with(iso.dayOfWeek(), 1L); // 本周最后一天 LocalDate lastDay = today.with(iso.dayOfWeek(), 7L);这里的关键是with(iso.dayOfWeek(), 1L),它的意思是把当前日期的“周内偏移”字段设置为1,也就是找到当前周的起始日。value等于1时返回本周第一天,value等于7时返回本周最后一天。如果你想用TemporalAdjusters实现同样的效果,就要根据“周第一日是周一还是周日”来选择previousOrSame的礼拜几参数。
我在实际项目里的建议是:如果系统的周统计口径固定,直接使用WeekFields.ISO,因为它是Java内置的常量,语义明确,其他同事看代码时不需要额外猜测。如果业务有特殊口径(比如“周日晚是起始”),则单独定义一个WeekFields常量,并写好注释,避免不同模块各写各的导致统计口径不一致。
3. 核心实操:天、周、月的开始时间和结束时间
3.1 获取当天的开始时间和结束时间
先写最基础的版本。假设需求是“查询今天产生的订单”,那么时间边界可以这样计算:
LocalDate today = LocalDate.now(); // 当天开始时间:00:00:00 LocalDateTime startOfDay = today.atStartOfDay(); // 方案A:当天最后一刻(不推荐用于查询) LocalDateTime endOfDayA = today.atTime(LocalTime.MAX); // 23:59:59.999999999 // 方案B:次日凌晨(推荐用于查询区间) LocalDateTime endOfDayB = today.plusDays(1).atStartOfDay(); // 明天00:00:00这里我直接把两种结束时间的写法都列出来了,并且建议大家用方案B。原因前面已经讲过了:LocalTime.MAX的纳秒精度和数据库精度不匹配,容易发生“四舍五入到次日”的问题。
在实际代码中,使用方案B的时间区间判断可以这样写:
LocalDateTime now = LocalDateTime.now(); boolean isToday = !now.isBefore(startOfDay) && now.isBefore(endOfDayB); // 等价于 SQL: create_time >= '2024-06-15 00:00:00' AND create_time < '2024-06-16 00:00:00'如果使用MyBatis-Plus的LambdaQueryWrapper,查询一天的数据就是:
LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<>(); wrapper.ge(Order::getCreateTime, LocalDate.now().atStartOfDay()) .lt(Order::getCreateTime, LocalDate.now().plusDays(1).atStartOfDay());这里额外提一个新手容易犯的错:直接用LocalDateTime.now()去对比“今天开始”,比如createTime.isAfter(LocalDate.now().atStartOfDay()) && createTime.isBefore(LocalDateTime.now()),这种写法不仅效率低,而且逻辑有漏洞——你拿一个动态变化的“当前时刻”作为结束边界,每毫秒都在变,根本不是一个确定的查询区间,极端情况下还会把正在写入的数据漏掉。
3.2 获取本周的开始时间和结束时间
假设系统约定周一是一周的第一天,那么获取本周时间范围最简版本是这样的:
LocalDate today = LocalDate.now(); // 本周一 00:00:00 LocalDate monday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); LocalDateTime startOfWeek = monday.atStartOfDay(); // 下周一 00:00:00(右开区间的结束点) LocalDateTime endOfWeek = monday.plusWeeks(1).atStartOfDay();就这么四行代码。它的好处是:不管今天是一周中的哪一天,它们都能正确返回本周一的零点。如果今天是周一,previousOrSame(MONDAY)返回的就是今天;如果今天是周四,返回这周一;如果今天是周日,仍然正确返回本周一,endOfWeek也不会出错。
如果你更习惯用WeekFields来定义“一周的第一天”,也可以写成:
WeekFields weekFields = WeekFields.of(DayOfWeek.MONDAY, 1); LocalDate monday = today.with(weekFields.dayOfWeek(), 1L); LocalDate sunday = today.with(weekFields.dayOfWeek(), 7L); LocalDateTime startOfWeek = monday.atStartOfDay(); LocalDateTime endOfWeek = sunday.plusDays(1).atStartOfDay();两种方式结果一样。区别在于:TemporalAdjusters版本更直白,读代码的人不需要理解WeekFields的机制;WeekFields版本则更适合那种“统计口径可能变化”的场景,因为只要换一个WeekFields定义,所有涉及“一周”的计算都会同步变化。
这里要特别警惕一个坑:如果你用nextOrSame(DayOfWeek.MONDAY)来获取本周一,当那天恰好是周日的时候,你会获得下周一的日期,而本周一实际上是七天前。这个问题在周报统计场景中非常容易触发,而且很难被自测发现,因为很多人习惯用周一以外的日期测试,结果代码逻辑貌似正确,等到周日跑批就开始出错。
3.3 获取本月的开始时间和结束时间
月初是最好算的,因为每个月的1号是固定的:
LocalDate today = LocalDate.now(); // 本月1号 00:00:00 LocalDate firstDay = today.with(TemporalAdjusters.firstDayOfMonth()); LocalDateTime startOfMonth = firstDay.atStartOfDay(); // 下月1号 00:00:00(右开区间的结束点) LocalDateTime endOfMonth = today.with(TemporalAdjusters.firstDayOfNextMonth()).atStartOfDay();这里不需要去判断当前是2月28号、4月30号还是12月31号,TemporalAdjusters把这些都处理好了。你甚至可以不用先拿firstDayOfMonth再plusMonths,直接调用firstDayOfNextMonth()更简洁。
还有一个写法也经常出现在项目里,就是拿“本月最后一天的最后一刻”作为结束时间:
LocalDate lastDay = today.with(TemporalAdjusters.lastDayOfMonth()); LocalDateTime endOfMonthClosed = lastDay.atTime(LocalTime.MAX);单独把值打印出来,它是某月最后一天的23:59:59.999999999,逻辑上确实“包含当月最后一刻”。但正如我前面反复强调的,查询场景千万慎用这种闭区间。我曾经在处理一个订单统计接口时,就因为用了这个写法,导致数据库把纳秒舍入成次日零点,结果次月1号零点整的几笔订单被月初统计重复计入,最后靠对账才发现。
所以我的最终建议还是统一右开区间:查当月数据就用[startOfMonth, 下月1号零点)。
3.4 扩展:上周、上月和自定义日期的区间计算
讲完本周本月,顺手把上周、上月的也讲了。做周报、月报时,跟上一周期做环比是非常常见的需求。
LocalDate today = LocalDate.now(); // 上周区间:[上周一 00:00:00, 本周一 00:00:00) LocalDate thisMonday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); LocalDate lastMonday = thisMonday.minusWeeks(1); LocalDateTime lastWeekStart = lastMonday.atStartOfDay(); LocalDateTime lastWeekEnd = thisMonday.atStartOfDay(); // 上月区间:[上月1号 00:00:00, 本月1号 00:00:00) LocalDate thisMonthFirstDay = today.with(TemporalAdjusters.firstDayOfMonth()); LocalDate lastMonthFirstDay = thisMonthFirstDay.minusMonths(1); LocalDateTime lastMonthStart = lastMonthFirstDay.atStartOfDay(); LocalDateTime lastMonthEnd = thisMonthFirstDay.atStartOfDay();看到规律了吗?上一周期的结束时间永远等于本周期的开始时间。只要抓住这个原则,不管往前推多少个周期,都不会乱。
更进一步,如果需求变成“给定任意一个LocalDate参数,求出它所在周/月的时间范围”,直接封装成函数:
public static LocalDateTime[] weekRange(LocalDate date) { LocalDate monday = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); LocalDateTime start = monday.atStartOfDay(); LocalDateTime end = monday.plusWeeks(1).atStartOfDay(); return new LocalDateTime[] {start, end}; } public static LocalDateTime[] monthRange(LocalDate date) { LocalDate firstDay = date.with(TemporalAdjusters.firstDayOfMonth()); LocalDateTime start = firstDay.atStartOfDay(); LocalDateTime end = firstDay.plusMonths(1).atStartOfDay(); return new LocalDateTime[] {start, end}; }这种“传一个日期,返回该日期所在周期范围”的函数在报表系统里非常常见,前端传一个用户选择的日期,后端算出这个日期对应的统计周期,可复用性很高。
另外补充一个实用小技巧:如果前端传的是字符串日期,比如“2024-06-15”,直接用LocalDate.parse就能解析:
LocalDate date = LocalDate.parse("2024-06-15"); LocalDateTime start = date.atStartOfDay(); LocalDateTime end = date.plusDays(1).atStartOfDay();LocalDate.parse默认按ISO_8601格式解析,即yyyy-MM-dd,大多数情况下够用。如果前端传的是“2024/06/15”这种格式,就需要显式指定DateTimeFormatter:
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd"); LocalDate date = LocalDate.parse("2024/06/15", formatter);4. 实战中的那些坑与解决方案
4.1 时区问题:LocalDateTime适用于哪些场景
我先说结论:绝大多数国内单时区业务,用LocalDateTime做时间计算和存储完全没问题。但如果你做的是跨国业务、或者服务器时区配置和业务时区不一致,就要格外小心。
LocalDateTime这个名字里的“Local”并不代表“本地时区”,而是代表“不携带时区信息的本地墙上时间”。它内部存的就是年月日时分秒纳秒,不关联任何时区偏移量。所以你拿LocalDateTime.now()获取到的其实是“当前JVM默认时区的墙上时间”,这个JVM默认时区可以通过ZoneId.systemDefault()查看。
一个很典型的线上问题是:服务器部署在境外机房,系统时区不是Asia/Shanghai,导致LocalDateTime.now()得到的时间和北京时间差了十几个小时。如果业务数据一直用这个时间存库,所有统计都会错乱。排查这种问题的时候,第一步一定是确认服务器时区,Linux下用date -R直接查看。
如果你自己确认不了JVM时区,可以在代码里临时加一行:
System.out.println(ZoneId.systemDefault());如果输出不是预期的Asia/Shanghai,就需要在应用启动参数里加上:
-Duser.timezone=Asia/Shanghai或者干脆在部署脚本里统一设定系统时区。对于真实跨时区业务,更稳妥的方案是把时间统一以UTC存储,展示时再按用户时区转换。这种场景下LocalDateTime就不太合适了,应该用Instant或者ZonedDateTime。
4.2 数据库查询边界:BETWEEN不是好选择
写SQL的时候,很多人习惯用BETWEEN:
SELECT * FROM orders WHERE create_time BETWEEN '2024-06-01 00:00:00' AND '2024-06-30 23:59:59'BETWEEN是闭区间,两个边界都包含。前面我已经反复提过,这会带来两个隐患:第一,如果你把精确到纳秒的时间传给秒级精度的datetime字段,数据库会做舍入,边界值可能变成下一天;第二,记者的业务日志可能会精确到毫秒,23:59:59.500那条记录会恰好被排除在你想要的这一天之外。
所以最佳实践是把SQL写成这样:
SELECT * FROM orders WHERE create_time >= '2024-06-01 00:00:00' AND create_time < '2024-07-01 00:00:00'这种写法在MySQL里用索引没有任何问题,而且语义非常清晰:“大于等于月初,小于下月月初”,不会产生任何歧义。就算换个新同事接手这段代码,也能一眼看出查询区间。
如果你用MyBatis-Plus,对应的就是ge和lt两个条件,而不是between。我见过有同事在代码里用lambdaQueryWrapper.between(...),传入的结束时间是23:59:59,看他查出来的数据总觉得哪里不对,后来一排查,发现又是精度舍入的老问题。从那以后我在code review里看到between配时间字段,基本都会多问一句:你确定不需要改成>=和<?
4.3 老项目集成:与java.util.Date互转的注意事项
很多老项目的实体类还在用java.util.Date,但现在新写的代码已经全面切到LocalDateTime了。这种情况下就需要在两者之间转换,转换本身不难,但时区问题一定要处理好。
// Date -> LocalDateTime(按系统默认时区) Date date = new Date(); LocalDateTime dateTime = date.toInstant() .atZone(ZoneId.systemDefault()) .toLocalDateTime(); // LocalDateTime -> Date(按系统默认时区) LocalDateTime localDateTime = LocalDateTime.now(); Date date = Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());注意两个转换代码里都出现了ZoneId.systemDefault(),这意味着转换结果受JVM默认时区影响。如果JVM配置的是UTC,而你的业务时区是北京,转换后的时间会相差8小时。所以老项目集成时,先确认服务器和JVM的时区配置是否统一,这一点在4.1节里已经强调过。
再补充一个关于DateTimeFormatter线程安全的对比。以前用SimpleDateFormat,官方文档明确说了它不是线程安全的,多线程共用同一个实例会造成解析结果错乱。而DateTimeFormatter是线程安全的,可以放心定义成static常量供全局使用。
private static final DateTimeFormatter DEFAULT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); String formatText = LocalDateTime.now().format(DEFAULT_FORMATTER); LocalDateTime parsed = LocalDateTime.parse("2024-06-15 10:30:00", DEFAULT_FORMATTER);这个优化虽然不起眼,但在高并发接口里能省掉不少ThreadLocal的折腾和安全隐患。
4.4 工程化落地:一个工具类搞定所有时间范围
既然项目中到处都需要这种“获取开始时间/结束时间”的逻辑,我建议统一封装到一个工具类里,避免每个业务类都写一遍。下面是我在实际项目里维护的一个精简版工具类,大家可以参考:
import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.temporal.TemporalAdjusters; public final class DateRangeUtils { private DateRangeUtils() { } /** * 某天的开始时间:00:00:00 */ public static LocalDateTime startOfDay(LocalDate date) { return date.atStartOfDay(); } /** * 某天的结束时间(右开区间):次日 00:00:00 */ public static LocalDateTime endOfDayExclusive(LocalDate date) { return date.plusDays(1).atStartOfDay(); } /** * 某周的开始时间(周一为一周开始):周一 00:00:00 */ public static LocalDateTime startOfWeek(LocalDate date) { return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) .atStartOfDay(); } /** * 某周的结束时间(右开区间):下周一 00:00:00 */ public static LocalDateTime endOfWeekExclusive(LocalDate date) { return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) .plusWeeks(1) .atStartOfDay(); } /** * 某月的开始时间:当月1号 00:00:00 */ public static LocalDateTime startOfMonth(LocalDate date) { return date.with(TemporalAdjusters.firstDayOfMonth()) .atStartOfDay(); } /** * 某月的结束时间(右开区间):下月1号 00:00:00 */ public static LocalDateTime endOfMonthExclusive(LocalDate date) { return date.with(TemporalAdjusters.firstDayOfNextMonth()) .atStartOfDay(); } }业务代码里用起来就非常简洁了:
LocalDateTime start = DateRangeUtils.startOfMonth(LocalDate.now()); LocalDateTime end = DateRangeUtils.endOfMonthExclusive(LocalDate.now()); // 把start和end传给查询条件这类工具类可以根据业务实际需要继续扩展,比如季度、半年、自然年。季度范围的计算思路也是一样的,右开区间:
public static LocalDateTime startOfQuarter(LocalDate date) { int month = date.getMonthValue(); int firstMonthOfQuarter = ((month - 1) / 3) * 3 + 1; return LocalDate.of(date.getYear(), firstMonthOfQuarter, 1).atStartOfDay(); } public static LocalDateTime endOfQuarterExclusive(LocalDate date) { LocalDate firstDayOfThisQuarter = LocalDate.of(date.getYear(), ((date.getMonthValue() - 1) / 3) * 3 + 1, 1); return firstDayOfThisQuarter.plusMonths(3).atStartOfDay(); }这种集中管理的模式还有一个额外的好处:如果以后产品经理说“我们的统计口径从周一改成周日”,你只需要改工具类里的DayOfWeek.MONDAY,所有调用方的逻辑同步生效,不会出现“改了A模块忘了B模块”的情况。
我个人在实际项目里的体会是:时间边界处理本身没有多难,真正难的是让整个团队都遵循同一套约定。只要大家统一用LocalDateTime、统一右开区间、统一走工具类,这类时间相关的bug基本可以杜绝。而且你一旦习惯了[start, end)这种思维,再回头看BETWEEN和23:59:59,会忍不住想把它们统统改掉。
最后再分享一个小技巧:排查时间相关问题时,一定要确认SQL实际收到的参数值,不要只盯着控制台的SQL模板看,因为MyBatis打印出来的?占位符是看不到实际值的。我在项目里一般会打印出start和end两个参数,或者直接配置MyBatis的日志参数打印,这样能省掉大量“明明写了条件怎么还查出脏数据”的排查时间。把这个习惯培养起来,时间范围相关的接口稳定度会高很多。