
做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的时间APIJSR-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(...)返回LocalDateLocalDateTime.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查询一天的数据就是LambdaQueryWrapperOrder 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”这种格式就需要显式指定DateTimeFormatterDateTimeFormatter 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.timezoneAsia/Shanghai或者干脆在部署脚本里统一设定系统时区。对于真实跨时区业务更稳妥的方案是把时间统一以UTC存储展示时再按用户时区转换。这种场景下LocalDateTime就不太合适了应该用Instant或者ZonedDateTime。4.2 数据库查询边界BETWEEN不是好选择写SQL的时候很多人习惯用BETWEENSELECT * FROM orders WHERE create_time BETWEEN 2024-06-01 00:00:00 AND 2024-06-30 23:59:59BETWEEN是闭区间两个边界都包含。前面我已经反复提过这会带来两个隐患第一如果你把精确到纳秒的时间传给秒级精度的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的日志参数打印这样能省掉大量“明明写了条件怎么还查出脏数据”的排查时间。把这个习惯培养起来时间范围相关的接口稳定度会高很多。