open1024

国际化后端与前端交互以时间戳交互

2025/10/27
5
0

自定义转换

方案A(推荐):DTO/VO用 Long 时间戳,服务层做统一转换

  • 前端传/收均为 epoch 毫秒(Long)。
  • 后端在服务层按用户/租户 ZoneId 转换:
    • Long → Instant → ZonedDateTime → LocalDate/LocalDateTime
    • LocalDate/LocalDateTime → ZonedDateTime → Instant → Long
  • 好处:时区明确,不会出现 LocalDate 的“前一天/后一天”偏移问题;实现简单、可控。
// 转换:Long 毫秒时间戳 → LocalDate
private LocalDate toLocalDate(Long ts, ZoneId zoneId) {
    return ts == null ? null : Instant.ofEpochMilli(ts).atZone(zoneId).toLocalDate();
}
// 转换:LocalDate → Long 毫秒时间戳(当天 00:00)
private Long toEpochMillis(LocalDate date, ZoneId zoneId) {
    return date == null ? null : date.atStartOfDay(zoneId).toInstant().toEpochMilli();
}

方案B:为查询参数注册全局 Converter(用于@RequestParam/PathVariable)

  • 让“字符串时间戳”自动转换到 LocalDate/LocalDateTime。

  • 注意:LocalDate 必须选定 ZoneId 才能从时间戳还原到日期。
    示例:

// 用于 ?ts=1730035200000 绑定到 LocalDateTime/LocalDate
@Component
public class EpochMilliToLocalDateTimeConverter implements Converter<String, LocalDateTime> {
    @Override
    public LocalDateTime convert(String source) {
        long millis = Long.parseLong(source);
        ZoneId zoneId = resolveZoneId(); // 从用户/租户/请求头解析
        return Instant.ofEpochMilli(millis).atZone(zoneId).toLocalDateTime();
    }
}

@Component
public class EpochMilliToLocalDateConverter implements Converter<String, LocalDate> {
    @Override
    public LocalDate convert(String source) {
        long millis = Long.parseLong(source);
        ZoneId zoneId = resolveZoneId();
        return Instant.ofEpochMilli(millis).atZone(zoneId).toLocalDate();
    }
}

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new EpochMilliToLocalDateTimeConverter());
        registry.addConverter(new EpochMilliToLocalDateConverter());
    }
}

方案C:请求体(JSON)为时间戳时,给 LocalDate/LocalDateTime 添加自定义反序列化

  • Jackson 默认更擅长解析 ISO-8601 字符串到 JavaTime;数值时间戳到 LocalDate/LocalDateTime 不一定按你预期,需要自定义反序列化器或用 @JsonFormat(shape = JsonFormat.Shape.NUMBER) 并配合 JavaTimeModule。
  • 稳妥做法是自定义反序列化器并在字段上标注:

可以通过两种方式实现数值时间戳与 LocalDate/LocalDateTime 的映射:

  • 全局注册自定义(反)序列化器,任何 JSON 请求体中的毫秒时间戳都能自动转为 JavaTime。
  • 在字段上使用 @JsonFormat(shape = JsonFormat.Shape.NUMBER) 搭配 JavaTimeModule,但不同版本的 Jackson 对 LocalDate/LocalDateTime 的数字形态支持不一致,稳妥做法仍是自定义反序列化器。

1. 全局自定义序列化/反序列化

下面给出“全局自定义序列化/反序列化”的落地代码,默认使用 Asia/Shanghai,如需从租户/用户时区解析,可把 ZoneId 的来源替换为你们现有的时区解析逻辑或请求头 X-Timezone。

为什么新增这些代码

  • 让 JSON 数值时间戳(毫秒)能自动映射到 LocalDate/LocalDateTime(请求体入参),并按同一时区把 LocalDate/LocalDateTime 序列化成毫秒时间戳(响应)。
package top.open1024.mewtwo.calendar.config;

import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.ZoneId;

@Configuration
public class JacksonConfig {

    @Bean
    public ZoneId defaultZoneId() {
        return ZoneId.of("Asia/Shanghai");
    }

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer javaTimeCustomizer(ZoneId defaultZoneId) {
        return builder -> {
            JavaTimeModule javaTimeModule = new JavaTimeModule();
            javaTimeModule.addDeserializer(java.time.LocalDateTime.class,
                    new top.open1024.mewtwo.calendar.config.time.EpochMillisLocalDateTimeDeserializer(defaultZoneId));
            javaTimeModule.addDeserializer(java.time.LocalDate.class,
                    new top.open1024.mewtwo.calendar.config.time.EpochMillisLocalDateDeserializer(defaultZoneId));
            javaTimeModule.addSerializer(java.time.LocalDateTime.class,
                    new top.open1024.mewtwo.calendar.config.time.EpochMillisLocalDateTimeSerializer(defaultZoneId));
            javaTimeModule.addSerializer(java.time.LocalDate.class,
                    new top.open1024.mewtwo.calendar.config.time.EpochMillisLocalDateSerializer(defaultZoneId));
            builder.featuresToEnable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
            builder.modules(new Module[]{javaTimeModule});
        };
    }
}
package top.open1024.mewtwo.calendar.config.time;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;

import java.io.IOException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;

public class EpochMillisLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {

    private final ZoneId zoneId;

    public EpochMillisLocalDateTimeDeserializer(ZoneId zoneId) {
        this.zoneId = zoneId;
    }

    @Override
    public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        long millis = p.getLongValue();
        return LocalDateTime.ofInstant(Instant.ofEpochMilli(millis), zoneId);
    }
}
package top.open1024.mewtwo.calendar.config.time;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;

import java.io.IOException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;

public class EpochMillisLocalDateDeserializer extends JsonDeserializer<LocalDate> {

    private final ZoneId zoneId;

    public EpochMillisLocalDateDeserializer(ZoneId zoneId) {
        this.zoneId = zoneId;
    }

    @Override
    public LocalDate deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        long millis = p.getLongValue();
        return Instant.ofEpochMilli(millis).atZone(zoneId).toLocalDate();
    }
}
package top.open1024.mewtwo.calendar.config.time;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;

import java.io.IOException;
import java.time.LocalDate;
import java.time.ZoneId;

public class EpochMillisLocalDateSerializer extends JsonSerializer<LocalDate> {

    private final ZoneId zoneId;

    public EpochMillisLocalDateSerializer(ZoneId zoneId) {
        this.zoneId = zoneId;
    }

    @Override
    public void serialize(LocalDate value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        long millis = value.atStartOfDay(zoneId).toInstant().toEpochMilli();
        gen.writeNumber(millis);
    }
}
package top.open1024.mewtwo.calendar.config.time;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.ZoneId;

public class EpochMillisLocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {

    private final ZoneId zoneId;

    public EpochMillisLocalDateTimeSerializer(ZoneId zoneId) {
        this.zoneId = zoneId;
    }

    @Override
    public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        long millis = value.atZone(zoneId).toInstant().toEpochMilli();
        gen.writeNumber(millis);
    }
}

2. 自定义反序列化器并在字段上标注方式

package top.open1024.mewtwo.calendar.config.time;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;

import java.io.IOException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;

/**
 * 将毫秒时间戳反序列化为 LocalDate(按 Asia/Shanghai 计算到当天)
 */
public class EpochMillisLocalDateDeserializer extends JsonDeserializer<LocalDate> {

    private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Shanghai");

    @Override
    public LocalDate deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        long millis = p.getLongValue();
        return Instant.ofEpochMilli(millis).atZone(DEFAULT_ZONE).toLocalDate();
    }
}
package top.open1024.mewtwo.calendar.config.time;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;

import java.io.IOException;
import java.time.LocalDate;
import java.time.ZoneId;

/**
 * 将 LocalDate 序列化为毫秒时间戳(当天 00:00 的 epoch millis,按 Asia/Shanghai)
 */
public class EpochMillisLocalDateSerializer extends JsonSerializer<LocalDate> {

    private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Shanghai");

    @Override
    public void serialize(LocalDate value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        if (value == null) {
            gen.writeNull();
        } else {
            long millis = value.atStartOfDay(DEFAULT_ZONE).toInstant().toEpochMilli();
            gen.writeNumber(millis);
        }
    }
}

使用方式

/**
     * 需要操作的实例日期(仅当 updateType 为 single 或 future 时需要)
     */
    @Schema(description = "需要操作的实例日期")
    // Deleted:@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
    @JsonDeserialize(using = EpochMillisLocalDateDeserializer.class)
    private LocalDate occurrenceDate;

    /**
     * 开始日期
     */
    // Deleted:@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
    @JsonDeserialize(using = EpochMillisLocalDateDeserializer.class)
    private LocalDate startDate;

    /**
     * 结束日期(重复餐食)
     */
    // Deleted:@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
    @JsonDeserialize(using = EpochMillisLocalDateDeserializer.class)
    private LocalDate untilDate;
  @Schema(description = "实际发生日期")
    // Deleted:@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
    @JsonSerialize(using = EpochMillisLocalDateSerializer.class)
    private LocalDate occurrenceDate;