上次在做接口联调时,发现返回给前端的创建时间是 2024-06-01T12:00:00,中间多了个T。前端同事在群里喊了一声“能不能把时间格式统一一下”,我打开实体类一看,还是老项目的 java.util.Date 加 SimpleDateFormat 那套操作。后来花了一天时间,把整个 Spring Boot 项目里的日期时间全部切换成 Java 8 的日期时间API,也就是 LocalDateTime、LocalDate、LocalTime、DateTimeFormatter 这一套,顺手把序列化、参数绑定、数据库映射、时区的坑都填了一遍。这篇文章就记录这些操作,适合正在写 Spring Boot 接口、定时任务、报表统计的后端同学参考。
1. 为什么Spring Boot项目要把j.u.Date扔掉——老方案的三个致命伤
1.1 SimpleDateFormat的线程安全隐患
在Java 8之前,几乎每个后端项目里都能看到这样的代码:
java复制private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public String formatTime(Date date) {
return sdf.format(date);
}
这段代码在单线程里运行没有任何问题,但一旦进入Spring Boot的并发容器(Tomcat默认200个线程),隐患就出来了。SimpleDateFormat内部维护了一个Calendar对象,format和parse都会修改它,多线程并发调用时,一个线程还没来得及读取计算结果,另一个线程就把Calendar改掉了。实际表现很随机:有时候抛出ParseException,有时候解析出的时间完全不对,月份变成13,日期变成0。这种bug在测试环境很难复现,因为并发量不够,一旦上线被流量一冲就暴露了。
1.2 Date和Calendar的设计混乱
java.util.Date从命名上看既表示“日期”又表示“时间点”,字段还带可变性,new Date()之后如果想改时间,得走setTime(long)修改内部毫秒值,破坏了不可变对象的直觉。Calendar就更不用说了,月份从0开始计数,get(Calendar.MONTH)返回的是0到11,跟日常习惯完全拧着来。而且这两个类打印出来的toString()格式极度不可读,比如Fri Jun 01 12:00:00 CST 2024,前端拿到这个字符串还得二次解析。在Spring Boot接口层,如果你直接把Date当作JSON字段返回,还需要额外配置格式,否则会输出一串时间戳数字,可读性很差。
1.3 Java 8给Spring Boot带来的新方案
Java 8在java.time包下重新设计了一套日期时间API,核心思想是“按需取用”:
- LocalDate:只有年月日,适合生日、入职日期这种场景;
- LocalTime:只有时分秒,适合每天的上下班打卡时间;
- LocalDateTime:年月日时分秒都有,业务里用得最多;
- Instant:时间戳,适合记录事件发生的绝对时刻;
- Duration和Period:分别表示基于秒/纳秒和基于年月日的时间间隔;
- DateTimeFormatter:格式化解析工具,而且它是不可变且线程安全的。
这套API的操作对象都是不可变类,每次调用withXxx、plusXxx、minusXxx都会返回一个新的对象,原对象不会被修改。这个设计直接影响并发安全,在Spring Boot这种天然多线程环境里特别重要。再加上Spring Boot 2.x的自动配置里已经内置了对java.time的支持,理论上引入成本很低,主要工作量在于统一项目里的使用规范。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 接口层序列化:让LocalDateTime按你想要的格式输出
2.1 不配置就返回,你会拿到什么
先说结论:Spring Boot 2.x里,LocalDateTime直接返回给前端,默认序列化结果是2024-06-01T12:00:00,带了一个T分隔符。这个格式符合ISO-8601标准,但很多前端同事并不习惯,尤其是要让表格组件直接展示时,多半得自己再处理一次。更麻烦的情况是,如果项目没有引入jackson-datatype-jsr310模块,或者还在用比较老的Spring Boot 1.x,直接序列化LocalDateTime会抛出InvalidDefinitionException,报错信息大概长这样:
text复制Java 8 date/time type `java.time.LocalDateTime` not supported by default:
add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310"
Spring Boot 2.x的spring-boot-starter-json已经自动带了jackson-datatype-jsr310,所以一般不会走到这一步。但默认格式不满足需求是大概率事件,我们就得动刀了。
2.2 全局ObjectMapper定制,一次配好所有接口
我的做法是提供一个Jackson2ObjectMapperBuilderCustomizer的Bean,定制LocalDateTime和LocalDate的序列化与反序列化格式:
java复制@Configuration
public class JacksonConfig {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> {
builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(DATE_TIME_FORMATTER));
builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(DATE_TIME_FORMATTER));
builder.serializerByType(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE));
builder.deserializerByType(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ISO_LOCAL_DATE));
builder.serializerByType(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm:ss")));
builder.deserializerByType(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm:ss")));
};
}
}
为什么用Jackson2ObjectMapperBuilderCustomizer而不是直接new一个ObjectMapper注册成Bean?因为Spring Boot自动配置里已经有自己组装好的ObjectMapper,里面包含了很多约定好的配置,直接替换很容易丢掉Spring Boot的默认行为。用Customizer只是在自动配置基础上“追加”设置,安全得多。配置完之后,接口返回的LocalDateTime就变成2024-06-01 12:00:00这种常见格式,前端拿来就能直接用。
要注意的是,这里同时配了序列化和反序列化,接收前端传来的JSON时间字符串时,也会按照同样的格式解析。如果前端可能传两种格式(比如一种是2024-06-01,一种是2024-06-01 12:00:00),就要考虑按需在字段上加@JsonFormat,或者在反序列化器里做更宽松的解析。
2.3 一个最容易被误导的配置:spring.jackson.date-format
很多人第一次遇到时间格式问题是去网上搜,搜到结果里最常出现的是在application.yml里写:
yaml复制spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
这段配置只对java.util.Date和java.util.Calendar生效,对LocalDateTime完全无效。原因很简单:LocalDateTime的序列化是由JavaTimeModule里的serializer控制的,spring.jackson.date-format配置的是SimpleDateFormat,处理的是老类型。如果你的实体类还是Date,这个配置够用;换成LocalDateTime就必须回到ObjectMapper定制那条路。
2.4 个别字段想特殊处理:用@JsonFormat
全局配置解决的是“统一格式”,但业务里总有例外。比如报表模块要返回2024年6月这种中文年月,而其他接口都保持yyyy-MM-dd HH:mm:ss。这时候就可以在字段上用@JsonFormat单独标注:
java复制@JsonFormat(pattern = "yyyy年MM月", timezone = "GMT+8")
private LocalDate reportMonth;
有一点要注意:@JsonFormat直接标注在LocalDateTime字段上,在Spring Boot 2.x里是生效的,并且同时影响序列化和反序列化。但timezone属性建议不要省,因为如果你不写,Jackson会使用ObjectMapper里的TimeZone,万一别的配置把它改成了UTC,就会出现本地时间被当成UTC时间输出的问题。这个细节在第五章会展开讲。
3. 参数绑定:前端字符串如何优雅地变成LocalDateTime
接口场景不只有返回JSON,还有接收参数。Spring Boot里接收时间参数主要分两条路:走@RequestBody的JSON反序列化,和走@RequestParam/@PathVariable的参数绑定。很多人在这里卡住,因为两条路的处理机制完全不同。
3.1 @RequestBody场景:跟随全局ObjectMapper
如果前端用POST提交,body是JSON字符串:
json复制{
"startTime": "2024-06-01 12:00:00",
"endTime": "2024-06-01 18:00:00"
}
后端用@RequestBody接:
java复制@PostMapping("/order/query")
public Result<List<Order>> queryOrders(@RequestBody OrderQuery query) {
// query.getStartTime() 已经是 LocalDateTime
}
这里走的是Jackson反序列化,上一章配置的LocalDateTimeDeserializer会直接生效,前端传什么格式就按什么格式解析。需要提醒的是,如果前端传的是2024-06-01T12:00:00这种ISO格式,而配置的是yyyy-MM-dd HH:mm:ss,就会抛出DateTimeParseException。遇到这种情况,要么统一前端格式,要么写一个自定义的反序列化器,支持多种格式匹配。
3.2 @RequestParam与表单字段:需要@DateTimeFormat或自定义Converter
GET请求或者表单提交时,参数走的是Spring MVC的ConversionService,根本不经过Jackson。所以就算你配好了ObjectMapper,下面这种写法依然会报错:
java复制@GetMapping("/order/query")
public Result<List<Order>> queryOrders(@RequestParam LocalDateTime startTime) {
// 前端传的是 2024-06-01 12:00:00 会解析失败
}
Spring Boot默认能支持的字符串格式只有ISO-8601,也就是2024-06-01T12:00:00。前端如果传带空格的格式,直接500。解决办法有两个:
方案一,在参数上标注@DateTimeFormat:
java复制@GetMapping("/order/query")
public Result<List<Order>> queryOrders(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime) {
// ...
}
方案二,定义全局Converter,让所有非JSON参数都能解析:
java复制@Component
public class StringToLocalDateTimeConverter implements Converter<String, LocalDateTime> {
private static final DateTimeFormatter DEFAULT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
@Override
public LocalDateTime convert(String source) {
if (source == null || source.isBlank()) {
return null;
}
String text = source.trim();
try {
return LocalDateTime.parse(text, DEFAULT_FORMATTER);
} catch (DateTimeParseException e) {
return LocalDateTime.parse(text, ISO_FORMATTER);
}
}
}
注册全局Converter还有一个副作用:如果项目里有些老接口本来就是用ISO格式传参的,一旦Converter只写了yyyy-MM-dd HH:mm:ss,这些老接口也会跟着报错。所以写Converter时最好做一个多格式兼容,把ISO格式作为兜底。
另外,如果同一个入参既配置了全局Converter,又写了@DateTimeFormat,实际生效的规则在不同Spring版本里并不完全一致,而且很容易让团队其他人困惑。我的建议是二选一,要么全局统一Converter,要么按字段用@DateTimeFormat,不要混着来,等出了问题再逐层排查很费时间。
3.3 一个完整的Controller示例
组合起来看一个完整例子:
java复制@RestController
@RequestMapping("/order")
public class OrderController {
@GetMapping("/list")
public Result<List<Order>> list(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate) {
// 数据库查询直接用 LocalDateTime 范围
LocalDateTime start = startDate.atStartOfDay();
LocalDateTime end = endDate.plusDays(1).atStartOfDay(); // 含结束当天
return Result.success(orderService.listByTime(start, end));
}
}
@DateTimeFormat(pattern = "yyyy-MM-dd")只能让字符串成功转成LocalDate,如果你需要LocalDateTime,还得手动在service里算。这也是一个高频笔试题:今天零点到明天零点之间,正确的查询条件是>= start AND < nextDayStart,而不是<= endOfDay,尤其当数据库字段是datetime类型时,精度问题很容易让边界数据漏掉或者重复。
4. 数据库映射:MyBatis与MySQL的日期时间对接细节
4.1 类型对应关系与MyBatis的自动映射
MyBatis从3.4.5版本开始,内置了java.time包的TypeHandler,LocalDateTime、LocalDate、LocalTime都可以和数据库字段自动映射。Spring Boot 2.x使用的MyBatis版本早就超过了这个门槛,所以不需要额外写TypeHandler。
MySQL类型的对应关系可以记成一张表:
| MySQL字段类型 | Java实体类型 | 使用场景 |
|---|---|---|
| datetime | LocalDateTime | 最常用,存年月日时分秒 |
| date | LocalDate | 只关心日期,比如生日 |
| time | LocalTime | 只关心时间,比如打卡 |
| timestamp | LocalDateTime 或 Instant | 注意时区换算,实际存的是UTC时间戳 |
| bigint | LocalDateTime(自行转换) | 有时间戳列的场景,查询时再转 |
这里最容易出错的是timestamp。MySQL的timestamp列在存储时会从当前连接时区转换成UTC,读取时再转回连接时区。如果你的连接参数里没有指定serverTimezone,就会使用数据库服务器的默认时区。同一时刻写入的数据,在不同时区的连接里读出来,显示结果会不一样。所以如果业务不需要跨时区,datetime比timestamp更直观,存进去什么读出来就是什么。
4.2 XML里的日期范围查询
日常开发里最常接触的就是按时间范围筛选。这行Mapper XML写得很频繁:
xml复制<select id="selectByCreateTime" resultType="com.example.entity.Order">
SELECT id, order_no, create_time
FROM t_order
WHERE create_time >= #{startTime}
AND create_time < #{endTime}
ORDER BY create_time DESC
</select>
注意XML里不能直接写<,要转义成<,或者用<![CDATA[ ]]>包起来。之前在代码评审里看到有人写<=没转义,XML直接解析失败,整页500。
Service层的起止时间计算建议这样写:
java复制public List<Order> queryByDay(LocalDate day) {
LocalDateTime start = day.atStartOfDay();
LocalDateTime end = day.plusDays(1).atStartOfDay();
return orderMapper.selectByCreateTime(start, end);
}
用“结束时间取下一天的零点”是为了避开LocalTime.MAX的精度陷阱。LocalTime.MAX是23:59:59.999999999,如果数据库字段是datetime(6)这种高精度类型,恰好有记录落在23:59:59.999999999,用<=排除时会漏掉,用< nextDay则永远是对的。
按时间范围查询的SQL,即便写对了起止条件,也别忘了在create_time列上建索引,否则数据量上来以后,一次统计查询会拖垮数据库。这是另一个话题,但在和日期时间相关的优化里值得提一嘴。
4.3 JDBC连接参数的坑:serverTimezone不能省
Mac或Linux开发机上,用MySQL Connector/J 5.1.33之后的版本连接MySQL 8.0,如果JDBC URL里不配serverTimezone,启动经常会报:
text复制The server time zone value 'XXX' is unrecognized or represents more than one time zone.
这是驱动检测不到数据库时区导致的。稳妥的做法是在连接串里显式指定:
text复制jdbc:mysql://localhost:3306/my_app?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
如果把项目部署到服务器,而服务器设置的是UTC时区,那么这个serverTimezone就应该跟着环境调整,或者统一所有环境都配置成Asia/Shanghai,避免同一条数据在不同环境里显示不同。这块属于环境配置问题,等线上出问题再排查成本很高,建议一开始就在配置中心把这条规则定死。
5. 时区是最大的暗坑:8小时误差从哪来、怎么治
5.1 四个容易各说各话的时区层
我在项目里总结过,时区问题至少要盯四个层面:
| 层面 | 配置位置 | 说明 |
|---|---|---|
| JVM时区 | TimeZone.setDefault / -Duser.timezone | 影响Date、Calendar在当前进程里的转换 |
| Jackson时区 | spring.jackson.time-zone | 影响JSON反序列化时String到带时区类型的转换 |
| JDBC连接时区 | serverTimezone | 影响数据库连接的时区上下文 |
| 数据库时区 | MySQL time_zone | 影响NOW()、CURRENT_TIMESTAMP等函数 |
LocalDateTime本身不携带时区信息,它在内存里就是“某年某月某日某时某分某秒”,没有世界标准时间的概念。真正出问题的场景,通常发生在它和带时区的类型(Date、Instant、Timestamp)互相转换的时候。
5.2 一次8小时误差的真实排查案例
之前做一个跨系统对接,系统A在国内,时区是东八区,接口返回字符串2024-06-01 12:00:00,表示北京时间中午12点。系统B部署在境外,JVM默认时区是UTC,它用java.util.Date来接收这个字段,Jackson反序列化时如果按UTC时区解析这个字符串,得到的Date对应的绝对时刻是UTC中午12点,也就是北京时间晚上8点。后续系统B再把这个Date转成LocalDateTime存库,或者返回给其他系统,就会出现8小时偏差。
这种问题的根子在于“用带时区的类型去接不带时区的字符串”。双方接口如果没有明确约定,就很容易出现。排查时不要急着改代码,先确认每个环节的时区配置,再逐层打印中间值。
5.3 统一时区的最佳实践
针对Spring Boot项目,我建议按下面几件事打好底子:
- JVM时区统一:如果是独立部署,可以在启动脚本里加
-Duser.timezone=GMT+8;如果是在容器里不方便改脚本,就在main方法或者某个@PostConstruct里加一句:
java复制TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
当然,如果项目明确要面向全球用户,那就不能用固定时区,而是按用户维度动态计算,这里讨论的是绝大多数国内业务场景。
- Jackson时区统一:在application.yml里显式配置:
yaml复制spring:
jackson:
time-zone: GMT+8
-
JDBC连接时区:连接串带上serverTimezone=Asia/Shanghai。
-
团队约定:接口层传输日期时间,一律使用
yyyy-MM-dd HH:mm:ss字符串,并且明确是东八区时间;数据库存储统一用datetime,不要混用timestamp和bigint。
验证时区是否统一有一个笨办法:在测试环境写一个临时接口,分别返回LocalDateTime.now().toString()、new Date().toString()和数据库里SELECT NOW()的结果。同一时刻三个值如果不在同一个时区语义上,就说明配置有地方没对齐。这几个点全部对齐之后,8小时问题基本不会出现。如果你接手的老项目里还有Date和LocalDateTime混用的代码,务必先梳理清楚每个Date是“表示时间点”还是“表示本地时间”,再决定怎么替换。
6. 日常业务里最常用的时间操作与工具沉淀
6.1 DateTimeFormatter:线程安全的格式化利器
以前用SimpleDateFormat,大家习惯在工具类里写一个static实例共享,结果就是线程安全问题。Java 8的DateTimeFormatter是线程安全的,可以放心地定义成static final:
java复制public final class TimeUtils {
private static final DateTimeFormatter DEFAULT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
private TimeUtils() {
}
public static String format(LocalDateTime time) {
return time == null ? null : DEFAULT_FORMATTER.format(time);
}
public static LocalDateTime parse(String text) {
return text == null || text.isBlank() ? null : LocalDateTime.parse(text, DEFAULT_FORMATTER);
}
public static String formatMonth(LocalDate date) {
return date == null ? null : MONTH_FORMATTER.format(date);
}
}
一个小小的建议:工具类方法里对null要宽容处理。接口层经常有可选参数,莫名其妙因为传了null字符串导致整个接口失败,体验很差。
6.2 高频时间计算:起止时间、周月边界、时间差
几个实际项目中常写的计算,直接抄作业:
java复制// 当天开始和结束
LocalDateTime startOfDay = LocalDate.now().atStartOfDay();
LocalDateTime endOfDay = LocalDate.now().atTime(LocalTime.MAX);
// 本周周一(从周一开始的一周)
LocalDate monday = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
LocalDate sunday = monday.plusDays(6);
// 本月第一天和最后一天
LocalDate firstDay = LocalDate.now().with(TemporalAdjusters.firstDayOfMonth());
LocalDate lastDay = LocalDate.now().with(TemporalAdjusters.lastDayOfMonth());
// 两个日期相隔多少天
long days = ChronoUnit.DAYS.between(startDate, endDate);
// 两个时间相隔多少小时多少分钟
Duration duration = Duration.between(startTime, endTime);
long minutes = duration.toMinutes();
// 判断是否同一天
boolean sameDay = localDateTime1.toLocalDate().isEqual(localDateTime2.toLocalDate());
这里要注意Period和Duration的区别。Period的字段是年月日这种“日历单位”,Duration是时分秒这种“精确单位”。比如Period.between两个LocalDate,得到的是“差了1年2个月3天”;而ChronoUnit.DAYS.between返回的是总天数。统计“订单平均处理时长”这种场景,用Duration更合适。
6.3 定时任务里的时间计算示例
Spring Boot项目里定时任务很常见,尤其是每天跑一次的统计报表。用上这套API之后,代码会简洁很多:
java复制@Scheduled(cron = "0 5 0 * * ?")
public void reportYesterday() {
LocalDate yesterday = LocalDate.now().minusDays(1);
LocalDateTime start = yesterday.atStartOfDay();
LocalDateTime end = yesterday.plusDays(1).atStartOfDay();
List<Order> orders = orderMapper.selectByCreateTime(start, end);
// 生成报表逻辑
}
这里用plusDays(1).atStartOfDay()作为结束边界,和前面数据库查询的写法保持一致,既不会被精度坑到,也方便直接从日志里确认统计范围。
6.4 和旧API的互转:老项目迁移时绕不开
如果项目里还有老代码在用Date,或者要调用第三方SDK返回的是Date,就需要互转。转换逻辑集中在少数几个方法里,避免散落各处:
java复制// LocalDateTime -> Date
public static Date toDate(LocalDateTime localDateTime) {
return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
}
// Date -> LocalDateTime
public static LocalDateTime toLocalDateTime(Date date) {
return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
}
// LocalDate -> Date(当天零点)
public static Date toDate(LocalDate localDate) {
return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
}
// LocalDateTime -> 时间戳(毫秒)
public static long toEpochMilli(LocalDateTime localDateTime) {
return localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
}
// 时间戳 -> LocalDateTime
public static LocalDateTime fromEpochMilli(long epochMilli) {
return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMilli), ZoneId.systemDefault());
}
注意转换时机和时区。比如从接口接收到一个字符串2024-06-01 12:00:00,正确的思路是用DateTimeFormatter先解析成LocalDateTime,再按需转成Date;反过来,如果先把字符串parse成Date,再转LocalDateTime,中间就多了一次基于JVM时区的换算,稍不留神就会引入时区差。这也是为什么我建议新写的代码尽量全程用LocalDateTime,只在边界(比如调用老SDK)做转换。
7. 从Date迁移到LocalDateTime的实操顺序与团队规范
如果你现在接手的是老项目,里面大量使用java.util.Date和SimpleDateFormat,想切换到Java 8这套API,别想着一次性搞完。我建议按这个顺序来:
- 先加Jackson全局配置,把接口出参格式稳住。这一步不影响现有代码,但能保证切换到LocalDateTime之后,前端看到的格式是统一的。
- 找一个不核心的实体类做试点,把Date字段替换成LocalDateTime,跑一遍单元测试和接口冒烟,确认序列化、反序列化、数据库读写都正常,再放开手脚铺开。
- 全局搜索
new Date()、new SimpleDateFormat()、Calendar.getInstance(),逐处替换。尤其是定时任务里,很多爱用Calendar算起止时间,替换成LocalDateTime之后代码会简洁很多。 - 检查所有Repository和Mapper XML里的时间参数类型。MyBatis自动映射虽然省事,但如果XML里手动写了jdbcType=DATE或jdbcType=TIMESTAMP,字段类型不匹配时仍可能报错,需要同步调整。
- 补一批时间工具类的单元测试。时区、边界值(23:59:59.999999999)、闰年、跨月这些都得覆盖,这类逻辑一旦写错很难通过肉眼发现。
最后给团队定几条简单易行的规范:
- 实体类的时间字段,新代码一律用LocalDateTime/LocalDate/LocalTime,不新增Date。
- 接口入参出参统一使用
yyyy-MM-dd HH:mm:ss字符串,不暴露时间戳数字。 - 数据库时间字段首选datetime,连接串必须带serverTimezone=Asia/Shanghai。
- 定时任务里的时间计算,只允许使用java.time包的工具类。
我个人实际踩过最深的坑,是把新项目时间全部换成LocalDateTime后,忘了在启动脚本里统一JVM时区,导致某个定时任务统计的报表在凌晨执行时总差一小时。后来把时区配置作为项目文档里必须检查的一项,再没出过类似问题。Java 8这套日期时间API本身不复杂,复杂的是项目和团队里各种历史遗留的约定。只要把序列化、参数绑定、数据库映射、时区这四件事理顺,Spring Boot项目里的日期时间处理会变得非常省心。
