|
|
@@ -0,0 +1,481 @@
|
|
|
+package com.payment.platform.common.handler;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
+import com.payment.platform.common.config.MyBatisPlusConfig;
|
|
|
+import org.apache.ibatis.type.BaseTypeHandler;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.sql.PreparedStatement;
|
|
|
+import java.sql.ResultSet;
|
|
|
+import java.sql.ResultSetMetaData;
|
|
|
+import java.sql.SQLException;
|
|
|
+import java.sql.Types;
|
|
|
+import java.time.Instant;
|
|
|
+import java.time.LocalDateTime;
|
|
|
+import java.time.OffsetDateTime;
|
|
|
+import java.time.ZoneId;
|
|
|
+import java.time.ZoneOffset;
|
|
|
+import java.util.concurrent.atomic.AtomicReference;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyInt;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.Mockito.doAnswer;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 钉住 {@link ShanghaiOffsetDateTimeTypeHandler} 的读写语义,以及它在 MyBatis 配置里的注册。
|
|
|
+ *
|
|
|
+ * <p><b>为什么存在</b>:pgjdbc 42.6.2 对 {@code timestamp without time zone}(OID 1114)
|
|
|
+ * 与 {@link OffsetDateTime} 的映射是<b>不对称</b>的 ——
|
|
|
+ * <ul>
|
|
|
+ * <li><b>读</b>:{@code PgResultSet.getOffsetDateTime} 把报文的裸墙钟交给
|
|
|
+ * {@code TimestampUtils.toOffsetDateTime(String)};而 {@code ParsedTimestamp}
|
|
|
+ * 的 {@code offset} 字段初值就是 {@link ZoneOffset#UTC},解析器在报文没有
|
|
|
+ * {@code +HH:MM} 后缀时<b>根本不修改该字段</b>(无后缀路径上没有任何对 {@code offset}
|
|
|
+ * 的写入),随后又无条件执行 {@code withOffsetSameInstant(UTC)}。
|
|
|
+ * 于是库里的墙钟被当成 <b>UTC 瞬间</b>读出来。</li>
|
|
|
+ * <li><b>写</b>:{@code ps.setObject(i, odt)} 时驱动按 ODT <b>自身</b>的 local 字段与偏移
|
|
|
+ * 发字面量,PostgreSQL 再按<b>会话时区</b>(实测 {@code Asia/Shanghai})把它归化成墙钟。</li>
|
|
|
+ * </ul>
|
|
|
+ * 两者叠加 ⇒ <b>每「读出 → 写回」一次,墙钟后移 8 小时</b>。修复前
|
|
|
+ * {@code updateById} 会把非 null 的 {@code created_time}/{@code updated_time} 一并写回,
|
|
|
+ * 于是每更新一次漂一次,与库中实测到的 <b>0 / +8 / +16</b> 三种偏移同源。
|
|
|
+ *
|
|
|
+ * <p><b>本测试不连数据库</b>:它用 {@link FakePgTimestampColumn} 把上面那些事实做成假体。
|
|
|
+ * 假体是否忠实的对照物是 {@link #builtinMappingShiftsByEightHoursPerRoundTrip()} ——
|
|
|
+ * 那一条喂给 MyBatis 自带的 {@code OffsetDateTimeTypeHandler},<b>必须</b>复现出 +8;
|
|
|
+ * 它一旦变绿,说明假体已失真,本文件其余结论也不再可信。
|
|
|
+ */
|
|
|
+class ShanghaiOffsetDateTimeTypeHandlerTest {
|
|
|
+
|
|
|
+ /** 生产的会话时区 —— TzProbe 实测为 Asia/Shanghai,也是假体的默认值。 */
|
|
|
+ private static final ZoneId SESSION_ZONE = ZoneId.of("Asia/Shanghai");
|
|
|
+
|
|
|
+ private static final ZoneOffset BEIJING_OFFSET = ZoneOffset.ofHours(8);
|
|
|
+
|
|
|
+ /** 库里的一个裸墙钟,取自实际出问题的那批数据。 */
|
|
|
+ private static final LocalDateTime WALL_CLOCK = LocalDateTime.of(2026, 9, 21, 15, 55, 9, 122_096_000);
|
|
|
+
|
|
|
+ // ==================== 假体 ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 一个 PostgreSQL 时间列在 pgjdbc 42.6.2 下的行为。两种列各一个实现。
|
|
|
+ *
|
|
|
+ * <p><b>{@link #columnTypeCode()} 对两种列都返回 93</b> —— 这不是笔误,是实测:
|
|
|
+ * pgjdbc 对 {@code timestamp} 与 {@code timestamptz} 给的 JDBC 类型码相同,
|
|
|
+ * 只有类型名区分得开。handler 若按类型码分支,就会让两种列走进同一条路。
|
|
|
+ */
|
|
|
+ interface FakePgColumn {
|
|
|
+
|
|
|
+ /** {@code getColumnTypeName()} 的返回值 —— 唯一能区分的信号。 */
|
|
|
+ String columnTypeName();
|
|
|
+
|
|
|
+ /** {@code getColumnType()} 的返回值 —— 实测两种列都是 93。 */
|
|
|
+ int columnTypeCode();
|
|
|
+
|
|
|
+ /** 模拟 {@code getObject(i, as)};驱动不支持的类型要按真实行为抛异常。 */
|
|
|
+ <T> T read(Class<T> as) throws SQLException;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 模拟一个 PostgreSQL {@code timestamp without time zone} 列,及其上 pgjdbc 42.6.2 的实测行为。
|
|
|
+ *
|
|
|
+ * <p>三个方法各自对应一条已核实的事实,注释里标明出处,避免以后有人凭印象改坏它。
|
|
|
+ */
|
|
|
+ static final class FakePgTimestampColumn implements FakePgColumn {
|
|
|
+
|
|
|
+ private LocalDateTime stored;
|
|
|
+
|
|
|
+ /** 数据库会话时区 —— 生产为 +08。个别用例故意换成别的,用来钉住写侧不依赖它。 */
|
|
|
+ private final ZoneId sessionZone;
|
|
|
+
|
|
|
+ FakePgTimestampColumn(LocalDateTime initial) {
|
|
|
+ this(initial, SESSION_ZONE);
|
|
|
+ }
|
|
|
+
|
|
|
+ FakePgTimestampColumn(LocalDateTime initial, ZoneId sessionZone) {
|
|
|
+ this.stored = initial;
|
|
|
+ this.sessionZone = sessionZone;
|
|
|
+ }
|
|
|
+
|
|
|
+ LocalDateTime storedWallClock() {
|
|
|
+ return stored;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public String columnTypeName() {
|
|
|
+ return "timestamp";
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public int columnTypeCode() {
|
|
|
+ // 实测:pgjdbc 对 timestamp 与 timestamptz 都返回 93
|
|
|
+ return Types.TIMESTAMP;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public <T> T read(Class<T> as) throws SQLException {
|
|
|
+ if (as == LocalDateTime.class) {
|
|
|
+ return as.cast(readAsLocalDateTime());
|
|
|
+ }
|
|
|
+ if (as == OffsetDateTime.class) {
|
|
|
+ return as.cast(readAsOffsetDateTime());
|
|
|
+ }
|
|
|
+ throw new SQLException("假体未预期的读取类型: " + as);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * pgjdbc 读 {@code getObject(i, LocalDateTime.class)}:
|
|
|
+ * {@code toLocalDateTime(String)} 只把解析出的年月日时分秒纳秒交给
|
|
|
+ * {@code LocalDateTime.of(...)},<b>全程不读 parsed.offset</b> ⇒ 墙钟原样返回。
|
|
|
+ */
|
|
|
+ LocalDateTime readAsLocalDateTime() {
|
|
|
+ return stored;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * pgjdbc 读 {@code getObject(i, OffsetDateTime.class)}:
|
|
|
+ * 解析器给无后缀报文定的 offset 是 UTC({@code ParsedTimestamp} 字段初值,
|
|
|
+ * 且无后缀路径不修改它),再 {@code withOffsetSameInstant(UTC)}
|
|
|
+ * ⇒ 局部字段仍是墙钟,偏移量恒为 {@code Z}。
|
|
|
+ */
|
|
|
+ OffsetDateTime readAsOffsetDateTime() {
|
|
|
+ return stored.atOffset(ZoneOffset.UTC);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * pgjdbc 写 {@code setObject(i, o)}(探针实测):
|
|
|
+ * 传 {@link OffsetDateTime} 时按它自身的局部字段与偏移构成瞬间,
|
|
|
+ * PostgreSQL 再按<b>会话时区</b>归化成墙钟;
|
|
|
+ * 传 {@link LocalDateTime} 时发裸字面量,原样落库。
|
|
|
+ *
|
|
|
+ * <p>其余类型一律拒绝 —— 免得 handler 塞进来一个意外类型时测试悄悄放行。
|
|
|
+ */
|
|
|
+ void write(Object parameter) {
|
|
|
+ if (parameter instanceof OffsetDateTime odt) {
|
|
|
+ stored = odt.toInstant().atZone(sessionZone).toLocalDateTime();
|
|
|
+ } else if (parameter instanceof LocalDateTime ldt) {
|
|
|
+ stored = ldt;
|
|
|
+ } else {
|
|
|
+ throw new IllegalArgumentException(
|
|
|
+ "假体未预期的参数类型: " + (parameter == null ? "null" : parameter.getClass()));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 模拟一个 PostgreSQL {@code timestamp with time zone} 列。
|
|
|
+ *
|
|
|
+ * <p><b>本类存在的原因</b>:修复的第一版 handler 读侧无条件
|
|
|
+ * {@code getObject(i, LocalDateTime.class)},对 {@code timestamptz} 列直接抛
|
|
|
+ * {@code Cannot convert the column of type TIMESTAMPTZ to requested type java.time.LocalDateTime},
|
|
|
+ * 而当时 114 个测试全绿 —— 因为假体只有 {@code timestamp} 一种,
|
|
|
+ * 这条路径<b>一次都没被走过</b>。所以这里把驱动的拒绝行为原样搬进来,
|
|
|
+ * 让「再退回无条件要 LocalDateTime」这件事必定变红。
|
|
|
+ *
|
|
|
+ * <p>两条已实测的事实:
|
|
|
+ * <ul>
|
|
|
+ * <li>读 {@code OffsetDateTime} 是<b>正确</b>的:库里的 {@code 15:55:09+08} 读出来
|
|
|
+ * 就是 {@code 07:55:09Z} 这个瞬间(而同样的值在 {@code timestamp} 列上会被读成
|
|
|
+ * {@code 15:55:09Z},那才是漂移的源头)。</li>
|
|
|
+ * <li>读 {@code LocalDateTime} <b>抛异常</b>,且类型码同样是 93。</li>
|
|
|
+ * </ul>
|
|
|
+ */
|
|
|
+ static final class FakePgTimestamptzColumn implements FakePgColumn {
|
|
|
+
|
|
|
+ /** 库里存的瞬间 —— 用 UTC 表达,与读出来的偏移量无关。 */
|
|
|
+ private final Instant storedInstant;
|
|
|
+
|
|
|
+ FakePgTimestamptzColumn(Instant storedInstant) {
|
|
|
+ this.storedInstant = storedInstant;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public String columnTypeName() {
|
|
|
+ return "timestamptz";
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public int columnTypeCode() {
|
|
|
+ // 实测:与 timestamp 同为 93 —— 按类型码分支必然踩雷
|
|
|
+ return Types.TIMESTAMP;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public <T> T read(Class<T> as) throws SQLException {
|
|
|
+ if (as == OffsetDateTime.class) {
|
|
|
+ return as.cast(storedInstant.atOffset(BEIJING_OFFSET));
|
|
|
+ }
|
|
|
+ if (as == LocalDateTime.class) {
|
|
|
+ // pgjdbc 42.6.2 原话,逐字照抄
|
|
|
+ throw new SQLException(
|
|
|
+ "Cannot convert the column of type TIMESTAMPTZ to requested type java.time.LocalDateTime.");
|
|
|
+ }
|
|
|
+ throw new SQLException("假体未预期的读取类型: " + as);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 把假体包装成 ResultSet,替 handler 提供 {@code getObject} 与列的元数据。
|
|
|
+ *
|
|
|
+ * <p>元数据必须一起给:handler 要靠 {@code getColumnTypeName} 分辨两种列,
|
|
|
+ * 而 {@code getColumnType} 对两者都报 93(所以假体也照实返回 93)。
|
|
|
+ */
|
|
|
+ private static ResultSet resultSetOver(FakePgColumn column) throws SQLException {
|
|
|
+ ResultSet rs = mock(ResultSet.class);
|
|
|
+ ResultSetMetaData meta = mock(ResultSetMetaData.class);
|
|
|
+ when(meta.getColumnTypeName(anyInt())).thenReturn(column.columnTypeName());
|
|
|
+ when(meta.getColumnType(anyInt())).thenReturn(column.columnTypeCode());
|
|
|
+ when(rs.getMetaData()).thenReturn(meta);
|
|
|
+ when(rs.findColumn(any())).thenReturn(1);
|
|
|
+ when(rs.getObject(anyInt(), eq(LocalDateTime.class)))
|
|
|
+ .thenAnswer(inv -> column.read(LocalDateTime.class));
|
|
|
+ when(rs.getObject(anyInt(), eq(OffsetDateTime.class)))
|
|
|
+ .thenAnswer(inv -> column.read(OffsetDateTime.class));
|
|
|
+ return rs;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 把假体包装成 PreparedStatement,把交给驱动的参数扣下来。 */
|
|
|
+ private static PreparedStatement sinkOver(FakePgTimestampColumn column) throws SQLException {
|
|
|
+ PreparedStatement ps = mock(PreparedStatement.class);
|
|
|
+ doAnswer(invocation -> {
|
|
|
+ column.write(invocation.getArgument(1));
|
|
|
+ return null;
|
|
|
+ }).when(ps).setObject(anyInt(), any());
|
|
|
+ return ps;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 走一遍生产路径:读出来,再写回去。 */
|
|
|
+ private static void roundTrip(BaseTypeHandler<OffsetDateTime> handler, FakePgTimestampColumn column)
|
|
|
+ throws SQLException {
|
|
|
+ OffsetDateTime loaded = handler.getNullableResult(resultSetOver(column), 1);
|
|
|
+ handler.setParameter(sinkOver(column), 1, loaded, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 本 handler 的契约 ====================
|
|
|
+
|
|
|
+ /** 读:库里的裸墙钟要按北京时间解释,而不是当成 UTC 瞬间。 */
|
|
|
+ @Test
|
|
|
+ void readsNaiveWallClockAsBeijingTime() throws SQLException {
|
|
|
+ FakePgTimestampColumn column = new FakePgTimestampColumn(WALL_CLOCK);
|
|
|
+
|
|
|
+ OffsetDateTime got = new ShanghaiOffsetDateTimeTypeHandler()
|
|
|
+ .getNullableResult(resultSetOver(column), 1);
|
|
|
+
|
|
|
+ assertEquals(WALL_CLOCK, got.toLocalDateTime(), "局部字段必须等于库里的墙钟");
|
|
|
+ assertEquals(BEIJING_OFFSET, got.getOffset(), "偏移量必须是 +08:00");
|
|
|
+ assertEquals(Instant.parse("2026-09-21T07:55:09.122096Z"), got.toInstant(), "对应的瞬间");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 写:交给驱动的必须是裸 {@link LocalDateTime}。
|
|
|
+ *
|
|
|
+ * <p>注意这一条<b>不是</b>修复的支点 —— 修掉漂移的是读侧。读侧交出来的值已带 +08,
|
|
|
+ * 所以在会话时区为 +08 时,发 {@code OffsetDateTime} 与发 {@code LocalDateTime}
|
|
|
+ * 落库结果相同(前者由驱动按会话时区归化,正好回到同一个墙钟)。
|
|
|
+ * 本条只钉住「发出去的是什么类型」,<b>抓不住写侧退化的变异</b>。
|
|
|
+ * 真正钉住那步归化的是 {@link #writeIsIndependentOfSessionTimeZone()}:会话时区一换,两条路才分开。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void writesNaiveLocalDateTimeNotOffsetDateTime() throws SQLException {
|
|
|
+ FakePgTimestampColumn column = new FakePgTimestampColumn(null);
|
|
|
+ AtomicReference<Object> captured = new AtomicReference<>();
|
|
|
+ PreparedStatement ps = mock(PreparedStatement.class);
|
|
|
+ doAnswer(invocation -> {
|
|
|
+ captured.set(invocation.getArgument(1));
|
|
|
+ return null;
|
|
|
+ }).when(ps).setObject(anyInt(), any());
|
|
|
+
|
|
|
+ new ShanghaiOffsetDateTimeTypeHandler()
|
|
|
+ .setParameter(ps, 1, WALL_CLOCK.atOffset(BEIJING_OFFSET), null);
|
|
|
+
|
|
|
+ assertInstanceOf(LocalDateTime.class, captured.get(),
|
|
|
+ "必须发裸 LocalDateTime;发 OffsetDateTime 会被驱动按会话时区归化");
|
|
|
+ assertEquals(WALL_CLOCK, captured.get());
|
|
|
+ // 顺带确认假体仍能接受这个参数(防止上面那条断言被写成永远为真)
|
|
|
+ column.write(captured.get());
|
|
|
+ assertEquals(WALL_CLOCK, column.storedWallClock());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 写:非北京偏移的 OffsetDateTime 必须先归到北京时间再剥掉偏移量。 */
|
|
|
+ @Test
|
|
|
+ void normalisesNonBeijingOffsetBeforeStripping() throws SQLException {
|
|
|
+ FakePgTimestampColumn column = new FakePgTimestampColumn(null);
|
|
|
+ PreparedStatement ps = sinkOver(column);
|
|
|
+
|
|
|
+ // 同一个瞬间,只是用 UTC 表达
|
|
|
+ OffsetDateTime asUtc = Instant.parse("2026-09-21T07:55:09.122096Z").atOffset(ZoneOffset.UTC);
|
|
|
+
|
|
|
+ new ShanghaiOffsetDateTimeTypeHandler().setParameter(ps, 1, asUtc, null);
|
|
|
+
|
|
|
+ assertEquals(WALL_CLOCK, column.storedWallClock(),
|
|
|
+ "UTC 表达的同一瞬间,落库应与北京表达完全一致");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 核心不变式:往返多次,墙钟逐纳秒不变。
|
|
|
+ *
|
|
|
+ * <p>库里那批数据的成因就是这条不变式被破坏,且破坏量随更新次数累积。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void repeatedRoundTripsLeaveWallClockUntouched() throws SQLException {
|
|
|
+ FakePgTimestampColumn column = new FakePgTimestampColumn(WALL_CLOCK);
|
|
|
+ BaseTypeHandler<OffsetDateTime> handler = new ShanghaiOffsetDateTimeTypeHandler();
|
|
|
+
|
|
|
+ for (int i = 0; i < 3; i++) {
|
|
|
+ roundTrip(handler, column);
|
|
|
+ }
|
|
|
+
|
|
|
+ assertEquals(WALL_CLOCK, column.storedWallClock(), "往返 3 次后墙钟仍应逐纳秒不变");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 写侧不依赖会话时区 ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 写:落库的墙钟与<b>会话时区</b>无关。
|
|
|
+ *
|
|
|
+ * <p>会话时区故意取 UTC(与生产不同),交进去一个 +08 的 {@link OffsetDateTime},
|
|
|
+ * 落库仍必须是北京墙钟。只发 ODT 的话驱动会按会话时区把它归化成 07:55。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void writeIsIndependentOfSessionTimeZone() throws SQLException {
|
|
|
+ FakePgTimestampColumn column = new FakePgTimestampColumn(null, ZoneOffset.UTC);
|
|
|
+
|
|
|
+ new ShanghaiOffsetDateTimeTypeHandler()
|
|
|
+ .setParameter(sinkOver(column), 1, WALL_CLOCK.atOffset(BEIJING_OFFSET), null);
|
|
|
+
|
|
|
+ assertEquals(WALL_CLOCK, column.storedWallClock(),
|
|
|
+ "会话时区换成 UTC 后仍应落北京墙钟 —— 只有发裸 LocalDateTime 才做得到");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 往返不变式同样不依赖会话时区。 */
|
|
|
+ @Test
|
|
|
+ void roundTripsAreIndependentOfSessionTimeZone() throws SQLException {
|
|
|
+ FakePgTimestampColumn column = new FakePgTimestampColumn(WALL_CLOCK, ZoneOffset.UTC);
|
|
|
+ BaseTypeHandler<OffsetDateTime> handler = new ShanghaiOffsetDateTimeTypeHandler();
|
|
|
+
|
|
|
+ for (int i = 0; i < 3; i++) {
|
|
|
+ roundTrip(handler, column);
|
|
|
+ }
|
|
|
+
|
|
|
+ assertEquals(WALL_CLOCK, column.storedWallClock(), "会话时区为 UTC 时往返 3 次墙钟仍不变");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== timestamptz 列:必须交回驱动原生映射 ====================
|
|
|
+
|
|
|
+ /** 一个 timestamptz 列里存的瞬间:2026-09-21 15:55:09.122096+08。 */
|
|
|
+ private static final Instant TZ_INSTANT = Instant.parse("2026-09-21T07:55:09.122096Z");
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 读 {@code timestamptz} 列:必须原样得到那个瞬间。
|
|
|
+ *
|
|
|
+ * <p>库里 {@code pay_bill.created_time}、{@code sys_user.last_login}、
|
|
|
+ * {@code sys_invitation_code.*} 都是这一类列。修复的第一版 handler 在这里会抛
|
|
|
+ * {@code Cannot convert the column of type TIMESTAMPTZ to requested type java.time.LocalDateTime},
|
|
|
+ * 整条查询失败 —— 所以本条的期望值是「不抛,且瞬间正确」。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void readsTimestamptzColumnThroughNativeDriverMapping() throws SQLException {
|
|
|
+ OffsetDateTime got = new ShanghaiOffsetDateTimeTypeHandler()
|
|
|
+ .getNullableResult(resultSetOver(new FakePgTimestamptzColumn(TZ_INSTANT)), 1);
|
|
|
+
|
|
|
+ assertEquals(TZ_INSTANT, got.toInstant(),
|
|
|
+ "timestamptz 存的是瞬间,读出来必须是同一个瞬间,不能被当成裸墙钟");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 按列名读(MyBatis 自动映射走的就是这条)也要进同一条分支。 */
|
|
|
+ @Test
|
|
|
+ void readsTimestamptzColumnByColumnNameToo() throws SQLException {
|
|
|
+ OffsetDateTime got = new ShanghaiOffsetDateTimeTypeHandler()
|
|
|
+ .getNullableResult(resultSetOver(new FakePgTimestamptzColumn(TZ_INSTANT)), "created_time");
|
|
|
+
|
|
|
+ assertEquals(TZ_INSTANT, got.toInstant());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** timestamptz 列为空时返回空,不要凭空造出一个时刻。 */
|
|
|
+ @Test
|
|
|
+ void nullTimestamptzStaysNull() throws SQLException {
|
|
|
+ ResultSet rs = mock(ResultSet.class);
|
|
|
+ ResultSetMetaData meta = mock(ResultSetMetaData.class);
|
|
|
+ when(meta.getColumnTypeName(anyInt())).thenReturn("timestamptz");
|
|
|
+ when(rs.getMetaData()).thenReturn(meta);
|
|
|
+ when(rs.getObject(anyInt(), eq(OffsetDateTime.class))).thenReturn(null);
|
|
|
+
|
|
|
+ assertNull(new ShanghaiOffsetDateTimeTypeHandler().getNullableResult(rs, 1));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 阳性对照:假体对 {@code timestamptz} 列要 {@link LocalDateTime} <b>必须抛</b>。
|
|
|
+ *
|
|
|
+ * <p>它一旦不抛,上面两条就失去了鉴别力 —— 旧代码也能"通过"。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void fakeTimestamptzRejectsLocalDateTimeRequest() {
|
|
|
+ FakePgTimestamptzColumn column = new FakePgTimestamptzColumn(TZ_INSTANT);
|
|
|
+
|
|
|
+ assertThrows(SQLException.class, () -> column.read(LocalDateTime.class),
|
|
|
+ "假体必须复现驱动的拒绝行为,否则本节的结论全都不可信");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 分支依据只能是<b>类型名</b>:pgjdbc 给两种列的类型码<b>相同</b>。
|
|
|
+ *
|
|
|
+ * <p>这条钉住「按 {@code getColumnType()} 分支」这个写法 ——
|
|
|
+ * 它编译得过、不会报错,却让 timestamptz 静默走进 timestamp 那条路。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void bothColumnKindsReportTheSameJdbcTypeCode() {
|
|
|
+ assertEquals(new FakePgTimestampColumn(WALL_CLOCK).columnTypeCode(),
|
|
|
+ new FakePgTimestamptzColumn(TZ_INSTANT).columnTypeCode(),
|
|
|
+ "两种列的类型码实测都是 93 —— 按类型码分支必然是错的");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 对照:假体的鉴别力 ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 阳性对照:MyBatis 自带的映射,往返<b>一次</b>就 +8 小时。
|
|
|
+ *
|
|
|
+ * <p>本条的期望值是「坏行为」。它必须一直是红的吗?不是 —— 它断言的就是这种坏行为,
|
|
|
+ * 所以它应当是绿的,且<b>它一旦变绿恰恰证明假体是忠实的</b>。
|
|
|
+ * 若哪天有人把假体改成「怎么读怎么写都不变」,本条会立刻失败,从而保护上面几条结论。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void builtinMappingShiftsByEightHoursPerRoundTrip() throws SQLException {
|
|
|
+ FakePgTimestampColumn column = new FakePgTimestampColumn(WALL_CLOCK);
|
|
|
+ BaseTypeHandler<OffsetDateTime> builtin = new org.apache.ibatis.type.OffsetDateTimeTypeHandler();
|
|
|
+
|
|
|
+ roundTrip(builtin, column);
|
|
|
+ assertEquals(WALL_CLOCK.plusHours(8), column.storedWallClock(),
|
|
|
+ "自带映射往返一次应 +8 小时;本条若失败说明假体已失真");
|
|
|
+
|
|
|
+ roundTrip(builtin, column);
|
|
|
+ assertEquals(WALL_CLOCK.plusHours(16), column.storedWallClock(),
|
|
|
+ "往返两次应 +16 小时 —— 与库中实测到的 0/+8/+16 同源");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 注册 ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 注册:{@link MyBatisPlusConfig} 暴露的 customizer 要能把本 handler 装成
|
|
|
+ * {@link OffsetDateTime} 的默认处理器,<b>覆盖 MyBatis 自带的那一个</b>。
|
|
|
+ *
|
|
|
+ * <p>这条不是在测 Spring,是在测「注册确实生效」—— 光有 handler 类而没装上,
|
|
|
+ * 线上行为与修复前一模一样,且不会有任何编译或运行期报错。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void customizerRegistersHandlerAsOffsetDateTimeDefault() {
|
|
|
+ MybatisConfiguration configuration = new MybatisConfiguration();
|
|
|
+
|
|
|
+ new MyBatisPlusConfig().offsetDateTimeTypeHandlerCustomizer().customize(configuration);
|
|
|
+
|
|
|
+ assertInstanceOf(ShanghaiOffsetDateTimeTypeHandler.class,
|
|
|
+ configuration.getTypeHandlerRegistry().getTypeHandler(OffsetDateTime.class),
|
|
|
+ "OffsetDateTime 的默认处理器必须已被替换,否则修复不生效");
|
|
|
+ }
|
|
|
+}
|