Sfoglia il codice sorgente

fix: 修复全库时间列漂移 - pgjdbc 读 timestamp 挂 UTC 导致每次 updateById 漂 +8h

根因:timestamp without time zone 与 OffsetDateTime 的映射不对称。
读侧把库里的墙钟当成 UTC 瞬间读出来,写回时又按会话时区归化,
于是每「读出 → updateById 写回」一次,时间列就 +8 小时。

- ShanghaiOffsetDateTimeTypeHandler:读侧按 getColumnTypeName() 分流。
  裸 timestamp 走 LocalDateTime 再挂 +08;timestamptz 交回驱动原生映射
  (getObject 向 timestamptz 列要 LocalDateTime 会直接抛 PSQLException)。
  ★ 分辨只能靠类型名:getColumnType() 对两者都返回 93,
  写成 == Types.TIMESTAMP_WITH_TIMEZONE 的分支编译得过、永不成立、也不报错。
  写侧发裸 LocalDateTime 字面量,实测对两类列都正确。
- MyBatisPlusConfig:走 ConfigurationCustomizer 显式注册。不能用
  type-handlers-package 扫包 —— 同包 JsonbTypeHandler 标着
  @MappedTypes(String.class),会被扫成全局 String 处理器,波及全库。
- application.yml:druid connection-init-sqls 把会话时区钉成 Asia/Shanghai,
  写侧的正确性押在它上面。
- db/timezone-drift-report.sql:只读受损清单。含 timestamptz 正对照 ——
  同一查询在会话 +08 与 UTC 下读数会平移,用于排除会话造成的假阳性。
- db/timezone-drift-repair.sql:一次性数据修复。含备份表、行数守卫、回滚,
  每个写操作后跟 COMMIT(缺 COMMIT 会让整笔改动随连接消失,而当场自查读到 0)。
  ★ 执行顺序是先发版、后修数据。

实测受损 16 表 / 328 行,漂移量恒为 8 小时的整数倍(n 跨 1..277)。
测试 119 绿。
alphaH 3 settimane fa
parent
commit
b5d6a60edf

+ 17 - 0
java/src/main/java/com/payment/platform/common/config/MyBatisPlusConfig.java

@@ -1,9 +1,11 @@
 package com.payment.platform.common.config;
 
 import com.baomidou.mybatisplus.annotation.DbType;
+import com.baomidou.mybatisplus.autoconfigure.ConfigurationCustomizer;
 import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
 import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
 import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
+import com.payment.platform.common.handler.ShanghaiOffsetDateTimeTypeHandler;
 import com.payment.platform.core.permission.DataScopeInnerInterceptor;
 import com.payment.platform.core.tenant.TenantInnerInterceptor;
 import org.springframework.context.annotation.Bean;
@@ -25,4 +27,19 @@ public class MyBatisPlusConfig {
         interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
         return interceptor;
     }
+
+    /**
+     * 把 {@link OffsetDateTime} 的默认类型处理器换成北京时间无损版。
+     *
+     * <p>必须走 {@code ConfigurationCustomizer} 显式注册,<b>不能</b>用
+     * {@code mybatis-plus.type-handlers-package} 扫包:同包的
+     * {@link com.payment.platform.common.handler.JsonbTypeHandler} 标着
+     * {@code @MappedTypes(String.class)},一旦被扫成 String 的默认处理器,
+     * 所有字符串参数都会以 {@code Types.OTHER} 发出 —— 那会波及全库每一张表。
+     */
+    @Bean
+    public ConfigurationCustomizer offsetDateTimeTypeHandlerCustomizer() {
+        return configuration -> configuration.getTypeHandlerRegistry()
+                .register(new ShanghaiOffsetDateTimeTypeHandler());
+    }
 }

+ 136 - 0
java/src/main/java/com/payment/platform/common/handler/ShanghaiOffsetDateTimeTypeHandler.java

@@ -0,0 +1,136 @@
+package com.payment.platform.common.handler;
+
+import org.apache.ibatis.type.BaseTypeHandler;
+import org.apache.ibatis.type.JdbcType;
+import org.apache.ibatis.type.MappedTypes;
+
+import java.sql.CallableStatement;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+
+/**
+ * {@link OffsetDateTime} ↔ PostgreSQL {@code timestamp without time zone} 的无损映射。
+ *
+ * <p><b>为什么需要它</b>:MyBatis 自带的 {@code OffsetDateTimeTypeHandler} 读用
+ * {@code rs.getObject(i, OffsetDateTime.class)}、写用 {@code ps.setObject(i, odt)},这两个调用
+ * 在 pgjdbc 42.6.2 上对 {@code timestamp without time zone}(OID 1114)<b>不对称</b>:
+ *
+ * <ul>
+ *   <li><b>读</b>把库里的裸墙钟当成 <b>UTC 瞬间</b>。{@code PgResultSet.getOffsetDateTime}
+ *       把报文交给 {@code TimestampUtils.toOffsetDateTime(String)};{@code ParsedTimestamp}
+ *       的 {@code offset} 字段初值就是 {@code ZoneOffset.UTC},而解析器在报文不带
+ *       {@code +HH:MM} 后缀时<b>根本不修改该字段</b>,随后又无条件
+ *       {@code withOffsetSameInstant(UTC)}。于是「15:55 的墙钟」变成「15:55Z 这个瞬间」。</li>
+ *   <li><b>写</b>按 ODT <b>自身</b>的局部字段与偏移发字面量,PostgreSQL 再按<b>会话时区</b>
+ *       把它归化成墙钟。</li>
+ * </ul>
+ *
+ * <p>两者叠加,每「读出 → 写回」一次墙钟后移 8 小时。MyBatis-Plus 的 {@code updateById}
+ * 默认({@code NOT_NULL} 策略)会把非 null 的 {@code created_time}/{@code updated_time}
+ * 一并写回,于是每更新一次漂一次 —— 这正是库中出现 0 / +8 / +16 三种偏移的成因。
+ *
+ * <p><b>修法</b>:改用 {@link LocalDateTime} 这个天然对应 {@code timestamp} 的载体。
+ * 读侧 {@code toLocalDateTime(String)} 只取年月日时分秒纳秒、全程不读 {@code offset};
+ * 写侧 {@code setObject(LocalDateTime)} 发裸字面量。两侧都不带时区,
+ * <b>对 {@code timestamp without time zone} 列的往返与数据库会话时区无关</b>。
+ *
+ * <p><b>约定</b>:本库的 {@code timestamp without time zone} 列一律存放<b>北京时间墙钟</b>
+ * —— 这与 {@code AutoFillMetaObjectHandler} 用 {@code Asia/Shanghai} 填 {@code createdTime}
+ * 的既有约定一致。
+ *
+ * <p><b>两类列必须分开走(2026-09-22 实测,不是推理)</b>:库里同时存在
+ * {@code timestamp without time zone} 与 {@code timestamp with time zone} 两种列,而
+ * {@code getObject(i, LocalDateTime.class)} <b>只对前者可用</b> —— 对 {@code timestamptz}
+ * pgjdbc 直接抛
+ * {@code Cannot convert the column of type TIMESTAMPTZ to requested type java.time.LocalDateTime},
+ * 整条查询失败。所以读侧必须先分辨列类型,只有裸 {@code timestamp} 才走「裸墙钟」这条路,
+ * {@code timestamptz} 交回驱动原生映射(它读 {@code timestamptz} 是正确的:
+ * 库里的 {@code 15:55:09+08} 读出来就是 {@code 07:55:09Z} 这个瞬间)。
+ *
+ * <p><b>★ 分辨只能按<b>类型名</b>,不能按 {@code getColumnType()}</b>:
+ * pgjdbc 42.6.2 对这两种列返回的 JDBC 类型码<b>都是 93</b>({@code Types.TIMESTAMP}),
+ * 只有 {@code getColumnTypeName()} 才区分得开({@code "timestamp"} / {@code "timestamptz"})。
+ * 写成 {@code == Types.TIMESTAMP_WITH_TIMEZONE} 会永远不成立,等于没改。
+ *
+ * <p><b>写侧为何不必分类型</b>:{@code setNonNullParameter} 拿不到目标列类型,
+ * 而实测(会话 +08 下,两种列各写一遍)表明发裸 {@link LocalDateTime} 对<b>两类列都正确</b>
+ * —— 对 {@code timestamp} 原样落墙钟,对 {@code timestamptz} 被 PostgreSQL 按会话时区
+ * 解释成同一个瞬间。这条正确性依赖会话时区为 {@code Asia/Shanghai},该前提由
+ * {@code spring.datasource.druid.connection-init-sqls} 在连接建立时显式设定。
+ * <b>改那一行配置会同时影响这两类列,不要单独动它。</b>
+ */
+@MappedTypes(OffsetDateTime.class)
+public class ShanghaiOffsetDateTimeTypeHandler extends BaseTypeHandler<OffsetDateTime> {
+
+    /** 北京时间 — 与 {@code AutoFillMetaObjectHandler} 保持同一个约定。 */
+    private static final ZoneId ZONE_BEIJING = ZoneId.of("Asia/Shanghai");
+
+    @Override
+    public void setNonNullParameter(PreparedStatement ps, int i, OffsetDateTime parameter, JdbcType jdbcType)
+            throws SQLException {
+        // 先归到北京时间,再剥掉偏移量 —— 只发裸墙钟,不带任何时区信息。
+        //
+        // 这一步的作用范围容易看错,写清楚:读侧已经保证本 handler 交出来的值带 +08,
+        // 所以对「本 handler 读出来的值」而言这一步是幂等的,它<b>不是</b>修掉漂移的那一步
+        // (修掉漂移靠的是读侧改用 LocalDateTime)。它管的是另外两件事:
+        //   1. 别处构造的 OffsetDateTime(第三方报文解析出的 UTC 偏移等)也会被归成北京时间墙钟;
+        //   2. 发裸 LocalDateTime 后,落库的墙钟与会话时区无关 —— 只写 ODT 的话,
+        //      会话时区一旦不是 +08,写出来的墙钟就会跟着变。
+        ps.setObject(i, parameter.atZoneSameInstant(ZONE_BEIJING).toLocalDateTime());
+    }
+
+    @Override
+    public OffsetDateTime getNullableResult(ResultSet rs, String columnName) throws SQLException {
+        return read(rs, rs.findColumn(columnName));
+    }
+
+    @Override
+    public OffsetDateTime getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
+        return read(rs, columnIndex);
+    }
+
+    @Override
+    public OffsetDateTime getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
+        ResultSetMetaData meta = cs.getMetaData();
+        // 存储过程的出参在 pgjdbc 上也能拿到结果集元数据;拿不到时退回驱动原生映射
+        // (本项目的存储过程为零,这条路实际不走,所以不为它做额外猜测)。
+        if (meta != null && isNaiveTimestamp(meta, columnIndex)) {
+            return toOffsetDateTime(cs.getObject(columnIndex, LocalDateTime.class));
+        }
+        return cs.getObject(columnIndex, OffsetDateTime.class);
+    }
+
+    /**
+     * 读一列。只有裸 {@code timestamp} 才走「裸墙钟按北京时间解释」;
+     * {@code timestamptz} 与其余类型一律交回驱动 —— 对 {@code timestamptz} 强行要
+     * {@link LocalDateTime} 会直接抛异常,整条查询失败。
+     */
+    private static OffsetDateTime read(ResultSet rs, int columnIndex) throws SQLException {
+        if (isNaiveTimestamp(rs.getMetaData(), columnIndex)) {
+            return toOffsetDateTime(rs.getObject(columnIndex, LocalDateTime.class));
+        }
+        return rs.getObject(columnIndex, OffsetDateTime.class);
+    }
+
+    /**
+     * 这一列是不是 {@code timestamp without time zone}。
+     *
+     * <p><b>按类型名判断,不能按 {@code getColumnType()}</b>:pgjdbc 42.6.2 对
+     * {@code timestamp} 与 {@code timestamptz} 返回的类型码<b>都是 93</b>,按类型码分支
+     * 会让两种列走进同一条路(实测:{@code timestamp} → 93 {@code "timestamp"},
+     * {@code timestamptz} → 93 {@code "timestamptz"})。
+     */
+    private static boolean isNaiveTimestamp(ResultSetMetaData meta, int columnIndex) throws SQLException {
+        return "timestamp".equalsIgnoreCase(meta.getColumnTypeName(columnIndex));
+    }
+
+    /** 裸墙钟按北京时间补齐偏移量;空值保持为空,不要凭空造出一个时刻。 */
+    private static OffsetDateTime toOffsetDateTime(LocalDateTime wallClock) {
+        return wallClock == null ? null : wallClock.atZone(ZONE_BEIJING).toOffsetDateTime();
+    }
+}

+ 14 - 0
java/src/main/resources/application.yml

@@ -35,6 +35,20 @@ spring:
       test-while-idle: true
       test-on-borrow: false
       test-on-return: false
+      # 每个物理连接建立后立刻把会话时区钉成北京时间。
+      #
+      # 为什么必须有这一行:本项目所有 timestamp without time zone 列一律存北京墙钟
+      # (由 ShanghaiOffsetDateTimeTypeHandler 保证),那部分与会话时区无关;
+      # 但业务表里另有 4 个 timestamptz 列存的是「瞬间」,读出来是会话时区的墙钟 ——
+      #   sys_invitation_code.used_time / created_time / updated_time、sys_user.last_login
+      # 这几列的正确性完全押在会话时区上,所以在这里显式钉死,不依赖服务端配置。
+      #
+      # 已核对 druid-spring-boot-3-starter 1.2.20 的 spring-configuration-metadata.json:
+      # 属性名就是 connection-init-sqls(Collection<String>);
+      # 并已反编译 DruidAbstractDataSource 确认它在建连后逐条
+      # connection.createStatement() + Statement.execute(sql)。
+      connection-init-sqls:
+        - SET TIME ZONE 'Asia/Shanghai'
 
   data:
     redis:

+ 341 - 0
java/src/main/resources/db/timezone-drift-repair.sql

@@ -0,0 +1,341 @@
+-- ============================================================================
+-- 时间列漂移 · 一次性数据修复
+--
+-- 【执行前必读】
+--   1. 本文件**改数据**,和隔壁 timezone-drift-report.sql 不是一回事。
+--   2. 它不在 db/migration/ 里,也不是迁移脚本 —— 请手工逐段跑,别交给任何自动工具。
+--   3. ★ 顺序是**先发版、后修数据**。旧程序每跑一次 updateById 就再漂一次,
+--      程序没换就修数据,等于一边擦一边写。修复见
+--      common/handler/ShanghaiOffsetDateTimeTypeHandler.java。
+--   4. ★★ 跑的顺序是 §0 → §1 → §5 → §2 → §3(§4 / §6 按需)。
+--      §5 那条诊断**必须在 §2 之前**跑 —— 它问的是「某列早于 created_time」,
+--      而 §2 干的正是把 created_time 往前挪。修完再问,这个不可能态反而更难出现,
+--      判据就失活了(2026-09-22 实测:修完 §5 返回「无数据」,不是没有坏列,是问不出来了)。
+--   5. ★★ **每个写操作后面都跟了 COMMIT,请连它一起执行,别只挑 SELECT 跑。**
+--      2026-09-22 第一次执行正是栽在这里:客户端在「不自动提交」模式下跑完 §1/§2/§3,
+--      会话里看到的是修好的值(§3 读到 0),事务却从未提交 ——
+--      连接一断(本库走隧道,抖动是常态)整笔回滚,库里一行没变。
+--      ★ §3 与 §2 在同一个事务里,**它读到的 0 不能证明落了盘**。
+--      加了 COMMIT 之后,§3 读的才是已提交状态,那个 0 才算数。
+-- ============================================================================
+--
+-- 这份脚本的依据(2026-09-22 全部当场重测,来源见 timezone-drift-report.sql §5/§6):
+--
+--   漂移家族:16 张表 / 328 行,created_time 为 timestamp without time zone。
+--   漂移量恒为 8 小时的**整数倍** n,n 跨 1..277;
+--   最大非整数余数 1.2 微秒(浮点噪声);
+--   无负漂移;无「不足一档」的行(|漂移| < 28800 的行数为 0)。
+--
+--   ★ created_time 与 updated_time 的 n 在全部 328 行上**逐一相等**
+--     (0 行 updated_time 为空,0 行 n 不一致)。
+--     把 updated_time 的输入人为 ±8h 扰动后,328 行全部报不一致 ——
+--     说明这个判据看得见一档之差,不是恒真的废话。所以两列可以各自按自己的 n 修。
+--
+-- ---------------------------------------------------------------------------
+-- ⚠️ 三类**不在**修复范围,动它们会毁数据或改错
+--
+--   1. `timestamp with time zone` 列 —— pay_bill.*、pay_lsy_*.*、sys_invitation_code.*、
+--      sys_user.last_login。它们存的是瞬间,读数随会话时区整体平移。
+--      正对照:pay_bill 的「漂移行」在会话 +08 下是 10 行,切成 UTC 就变 523 行。
+--      那是会话造的假象,不是数据坏了。本脚本的 16 张表已按列类型筛过,别再手工加。
+--
+--   2. pay_transfer 的 21834 行**旧序号 id**(id ≤ 1e18,2026-04-22 ~ 05-26)。
+--      雪花公式对它们无意义,唯一可能的参照物 next_retry_at 在这些行上对不上
+--      (差值散在 115111 / 76829 / 9936 … 秒,不是 8h 的整数倍)。
+--      ⇒ 这批行的漂移量**无法测定**,因此一行都不动。所有语句都带 id > 1e18。
+--
+--   3. created_time / updated_time 之外的其它时间列(见 §5)。
+--      它们同样可能漂过,但每行漂了几档**无法从数据反推**,硬修就是编数字。
+-- ============================================================================
+
+
+-- ---------------------------------------------------------------------------
+-- §0 先看:这次会改哪些行(只读,跑完肉眼核对)
+-- ---------------------------------------------------------------------------
+SELECT 'pay_transfer' AS tbl, count(*) AS 将修正行数 FROM pay_transfer
+ WHERE id > 1e18 AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+UNION ALL SELECT 'pay_employee', count(*) FROM pay_employee
+ WHERE id > 1e18 AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+UNION ALL SELECT 'pay_expense_quota', count(*) FROM pay_expense_quota
+ WHERE id > 1e18 AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+UNION ALL SELECT 'pay_batch_order', count(*) FROM pay_batch_order
+ WHERE id > 1e18 AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+UNION ALL SELECT 'pay_batch_detail', count(*) FROM pay_batch_detail
+ WHERE id > 1e18 AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+UNION ALL SELECT 'pay_enterprise', count(*) FROM pay_enterprise
+ WHERE id > 1e18 AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+UNION ALL SELECT 'pay_account', count(*) FROM pay_account
+ WHERE id > 1e18 AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+UNION ALL SELECT 'pay_f2f_trade', count(*) FROM pay_f2f_trade
+ WHERE id > 1e18 AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+UNION ALL SELECT 'pay_batch_authorize', count(*) FROM pay_batch_authorize
+ WHERE id > 1e18 AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+UNION ALL SELECT 'pay_facetoface_order', count(*) FROM pay_facetoface_order
+ WHERE id > 1e18 AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+UNION ALL SELECT 'pay_expense_institution', count(*) FROM pay_expense_institution
+ WHERE id > 1e18 AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+UNION ALL SELECT 'open_conf', count(*) FROM open_conf
+ WHERE id > 1e18 AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+ ORDER BY 2 DESC;
+-- 各表应为:250 / 16 / 13 / 12 / 11 / 8 / 5 / 4 / 3 / 3 / 2 / 1,合计 328。
+-- 对不上就停下 —— 说明库已经不是你测的那个库,别继续。
+
+
+-- 抽样几行看「改前 → 改后」长什么样(只读)
+SELECT id, status,
+       created_time AS "改前",
+       created_time - make_interval(hours => (round(extract(epoch FROM (created_time
+             - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) / 28800.0)::int * 8) AS "改后",
+       (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai') AS "雪花反解的真实时间"
+  FROM pay_transfer
+ WHERE id > 1e18
+   AND abs(extract(epoch FROM (created_time
+         - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+ ORDER BY id DESC
+ LIMIT 10;
+-- 「改后」应当与「雪花反解的真实时间」同日同时刻,只差毫秒级。
+
+
+-- ---------------------------------------------------------------------------
+-- §1 备份(★ 必做。没跑这一步不要往下走)
+--
+--     用纯 CREATE TABLE,不用 DROP IF EXISTS —— 万一之前跑过,
+--     这里会报「已存在」而不是把上一份备份悄悄覆盖掉。
+--     真报了已存在,先查清楚那份是什么再说。
+--
+--     备份表记录了每行的 n,修完之后 n 就推不出来了(校正后 n 恒为 0),
+--     所以备份是**唯一**的回退依据。
+-- ---------------------------------------------------------------------------
+CREATE TABLE tz_drift_backup_20260922 AS
+WITH src AS (
+    SELECT 'pay_transfer' AS tbl, id, created_time, updated_time FROM pay_transfer WHERE id > 1e18
+    UNION ALL SELECT 'pay_employee', id, created_time, updated_time FROM pay_employee WHERE id > 1e18
+    UNION ALL SELECT 'pay_expense_quota', id, created_time, updated_time FROM pay_expense_quota WHERE id > 1e18
+    UNION ALL SELECT 'pay_batch_order', id, created_time, updated_time FROM pay_batch_order WHERE id > 1e18
+    UNION ALL SELECT 'pay_batch_detail', id, created_time, updated_time FROM pay_batch_detail WHERE id > 1e18
+    UNION ALL SELECT 'pay_enterprise', id, created_time, updated_time FROM pay_enterprise WHERE id > 1e18
+    UNION ALL SELECT 'pay_account', id, created_time, updated_time FROM pay_account WHERE id > 1e18
+    UNION ALL SELECT 'pay_f2f_trade', id, created_time, updated_time FROM pay_f2f_trade WHERE id > 1e18
+    UNION ALL SELECT 'pay_batch_authorize', id, created_time, updated_time FROM pay_batch_authorize WHERE id > 1e18
+    UNION ALL SELECT 'pay_facetoface_order', id, created_time, updated_time FROM pay_facetoface_order WHERE id > 1e18
+    UNION ALL SELECT 'pay_expense_institution', id, created_time, updated_time FROM pay_expense_institution WHERE id > 1e18
+    UNION ALL SELECT 'open_conf', id, created_time, updated_time FROM open_conf WHERE id > 1e18
+), d AS (
+    SELECT tbl, id, created_time, updated_time,
+           extract(epoch FROM (created_time
+                 - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai'))) AS drift
+      FROM src
+)
+SELECT tbl, id, created_time, updated_time,
+       round(drift / 28800.0)::int AS n,
+       now() AS backed_up_at
+  FROM d
+ WHERE abs(drift) >= 60;
+
+-- 备份必须正好 328 行。不是 328 就停下,别进 §2。
+SELECT count(*) AS 备份行数 FROM tz_drift_backup_20260922;
+
+COMMIT;   -- ★ 必须。备份没落盘就进 §2,等于没有回退依据。
+
+
+-- ---------------------------------------------------------------------------
+-- §2 修复(一个 DO 块 = 一个事务:要么 16 张表全改完,要么一行都不改)
+--
+--     块内先重数一遍,不等于 328 就抛异常回滚。
+--     这条守卫同时保证**重复执行安全**:修完再跑一次,数出来是 0,直接中止。
+--
+--     两列各按自己的 n 校正(而不是共用 created_time 的 n)。
+--     实测两者相等,但万一将来出现 updated_time 没漂的行,
+--     「各按各的」会正确地放过它,「共用」会把它多减 8h。
+-- ---------------------------------------------------------------------------
+DO $$
+DECLARE
+    tabs    text[] := ARRAY['pay_transfer','pay_employee','pay_expense_quota','pay_batch_order',
+                            'pay_batch_detail','pay_enterprise','pay_account','pay_f2f_trade',
+                            'pay_batch_authorize','pay_facetoface_order','pay_expense_institution',
+                            'open_conf'];
+    expected bigint := 328;
+    t        text;
+    v_rows   bigint;
+    v_total  bigint := 0;
+    v_sum    bigint := 0;
+BEGIN
+    -- 守卫:先数,不改
+    FOREACH t IN ARRAY tabs LOOP
+        EXECUTE format($f$
+            SELECT count(*) FROM %I
+             WHERE id > 1e18
+               AND abs(extract(epoch FROM (created_time
+                     - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+        $f$, t) INTO v_rows;
+        v_total := v_total + v_rows;
+    END LOOP;
+
+    IF v_total <> expected THEN
+        RAISE EXCEPTION '可修行数 % ≠ 期望的 %,已中止,未改动任何数据', v_total, expected;
+    END IF;
+
+    -- 逐表修
+    FOREACH t IN ARRAY tabs LOOP
+        EXECUTE format($f$
+            UPDATE %I SET
+              created_time = created_time - make_interval(hours =>
+                  (round(extract(epoch FROM (created_time
+                       - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) / 28800.0)::int * 8),
+              updated_time = updated_time - make_interval(hours =>
+                  (round(extract(epoch FROM (updated_time
+                       - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) / 28800.0)::int * 8)
+             WHERE id > 1e18
+               AND abs(extract(epoch FROM (created_time
+                     - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+        $f$, t);
+        GET DIAGNOSTICS v_rows = ROW_COUNT;
+        v_sum := v_sum + v_rows;
+        RAISE NOTICE '% → % 行', t, v_rows;
+    END LOOP;
+
+    RAISE NOTICE '合计修正 % 行', v_sum;
+END $$;
+
+-- ★★ 必须,且必须在 §3 **之前**。
+--    只跑 DO 块不跑这一句,改动只活在当前事务里:§3 会读到 0(因为看的就是自己没提交的改动),
+--    而库里一行没变。COMMIT 之后 §3 读到的才是真的落了盘的数。
+COMMIT;
+
+
+-- ---------------------------------------------------------------------------
+-- §3 验证:应全部为 0
+--
+--     created_time 与雪花反解的时间差若落在 1 秒内,就算修好了。
+--     updated_time 不参与这条 —— 它天然比 created_time 晚(真实流逝时间),
+--     只能验证「两列 n 不再不一致」,见第二条。
+-- ---------------------------------------------------------------------------
+SELECT count(*) AS 仍在漂移的行数
+  FROM (
+    SELECT id FROM pay_transfer WHERE id > 1e18
+      AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+    UNION ALL SELECT id FROM pay_employee WHERE id > 1e18
+      AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+    UNION ALL SELECT id FROM pay_expense_quota WHERE id > 1e18
+      AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+    UNION ALL SELECT id FROM pay_batch_order WHERE id > 1e18
+      AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+    UNION ALL SELECT id FROM pay_batch_detail WHERE id > 1e18
+      AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+    UNION ALL SELECT id FROM pay_enterprise WHERE id > 1e18
+      AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+    UNION ALL SELECT id FROM pay_account WHERE id > 1e18
+      AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+    UNION ALL SELECT id FROM pay_f2f_trade WHERE id > 1e18
+      AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+    UNION ALL SELECT id FROM pay_batch_authorize WHERE id > 1e18
+      AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+    UNION ALL SELECT id FROM pay_facetoface_order WHERE id > 1e18
+      AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+    UNION ALL SELECT id FROM pay_expense_institution WHERE id > 1e18
+      AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+    UNION ALL SELECT id FROM open_conf WHERE id > 1e18
+      AND abs(extract(epoch FROM (created_time - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+  ) x;
+
+-- 抽 10 行核对:created_time 应等于雪花反解值,updated_time 应 ≥ created_time
+SELECT id, created_time, updated_time,
+       round(extract(epoch FROM (created_time
+             - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai'))))::bigint AS "与雪花的差(秒,应为0)",
+       round(extract(epoch FROM (updated_time - created_time)))::bigint AS "updated比created晚(秒,应≥0)"
+  FROM pay_transfer
+ WHERE id > 1e18
+ ORDER BY id DESC
+ LIMIT 10;
+
+
+-- ---------------------------------------------------------------------------
+-- §4 回滚(只在 §3 没通过时用;平时不必跑)
+--
+--     从备份表逐表还原。同样包在一个 DO 块里,要么全还原要么不动。
+-- ---------------------------------------------------------------------------
+DO $$
+DECLARE
+    tabs text[] := ARRAY['pay_transfer','pay_employee','pay_expense_quota','pay_batch_order',
+                         'pay_batch_detail','pay_enterprise','pay_account','pay_f2f_trade',
+                         'pay_batch_authorize','pay_facetoface_order','pay_expense_institution',
+                         'open_conf'];
+    t      text;
+    v_rows bigint;
+    v_sum  bigint := 0;
+BEGIN
+    FOREACH t IN ARRAY tabs LOOP
+        EXECUTE format($f$
+            UPDATE %I p
+               SET created_time = b.created_time,
+                   updated_time = b.updated_time
+              FROM tz_drift_backup_20260922 b
+             WHERE b.tbl = %L AND b.id = p.id
+        $f$, t, t);
+        GET DIAGNOSTICS v_rows = ROW_COUNT;
+        v_sum := v_sum + v_rows;
+        RAISE NOTICE '还原 % → % 行', t, v_rows;
+    END LOOP;
+    RAISE NOTICE '合计还原 % 行(应为 328)', v_sum;
+END $$;
+
+COMMIT;   -- ★ 同上,回滚也要落盘。
+
+
+-- ---------------------------------------------------------------------------
+-- §5 没被修的那些时间列 —— 只读诊断,看清了再决定要不要单独处理
+--
+--     ★★ 这一段要在 §2 **之前**跑(原因见开头「执行前必读」第 4 条)。
+--     2026-09-22 实测:修完之后它返回「无数据」。那不是「没有坏列」,
+--     是判据已经问不出话了 —— created_time 被挪准之后,
+--     「某列早于 created_time」这个不可能态自然更难成立。
+--     而且它现在连正对照都没有:pay_account 的 7 行雪花 id 里 sign_time 全为 NULL。
+--
+--     这 16 张表除 created_time / updated_time 外还有这些裸 timestamp 列:
+--       pay_transfer            next_retry_at        ← 已证清白,见下
+--       pay_expense_quota       valid_from, valid_to
+--       pay_batch_order         time_expire
+--       pay_enterprise          expire_time, invite_time, name_sync_next_time
+--       pay_account             sign_time
+--       pay_f2f_trade           gmt_create, gmt_payment, last_query_time, next_query_time
+--       pay_batch_authorize     authorize_expire_time
+--       pay_facetoface_order    last_query_time, next_query_time
+--       pay_expense_institution effective_start_date, effective_end_date
+--
+--     它们**可能**同样漂过(漂移发生在读出→写回之间,与是哪一列无关),
+--     但每行漂了几档无法从数据反推:这些列各自在某个时刻被写入,
+--     之后每经历一次 updateById 就 +8h,而「写于何时」无从得知。
+--     硬修就是编数字,所以本脚本一行都不动它们。
+--
+--     唯一例外是 pay_transfer.next_retry_at —— 它在插入时写一次,
+--     之后所有更新路径都 setNextRetryAt(null),被 MyBatis-Plus 的 NOT_NULL 策略
+--     排除出 SET 子句,所以从没被写回过。它和雪花反解值的最大偏差只有 0.155 秒,
+--     两个独立来源互相印证 —— 这既是它清白的证据,
+--     也是「漂移只发生在被写回的列上」这个判断的实证。
+--
+--     下面这条只读查询把可疑行捞出来供人工判断:
+--     真实值必然落在 [真实created_time, 真实updated_time] 之外的**过去**事件列,
+--     一定是错的。注意只适用于「记录已发生事件」的列
+--     (sign_time / gmt_create / gmt_payment / invite_time / last_query_time);
+--     expire_time / valid_to / time_expire / next_query_time 这类**指向未来**的列
+--     本来就可以晚于 updated_time,不能拿这个判据去套。
+-- ---------------------------------------------------------------------------
+SELECT id, created_time AS created_time_已校正, updated_time AS updated_time_已校正,
+       sign_time,
+       round(extract(epoch FROM (sign_time - created_time)))::bigint AS "sign_time比created晚(秒,负值即不可能)"
+  FROM pay_account
+ WHERE id > 1e18 AND sign_time IS NOT NULL
+   AND sign_time < created_time
+ ORDER BY id DESC
+ LIMIT 20;
+
+
+-- ---------------------------------------------------------------------------
+-- §6 收尾
+--
+--     §3 验证通过、页面核对无误之后,再考虑删备份表。
+--     删之前先把备份导出留档:
+--       \copy (SELECT * FROM tz_drift_backup_20260922) TO 'tz_drift_backup_20260922.csv' CSV HEADER
+--     确认那份 CSV 落地了,再执行:
+--       DROP TABLE tz_drift_backup_20260922;
+-- ---------------------------------------------------------------------------

+ 246 - 0
java/src/main/resources/db/timezone-drift-report.sql

@@ -0,0 +1,246 @@
+-- ============================================================================
+-- 时间列漂移 · 只读受损清单
+--
+-- 【本文件不修改任何数据,也永远不要放进 migration 目录执行】
+-- 它只跑 SELECT。放在这里是因为你习惯在这个目录找 SQL。
+--
+-- 背景:pgjdbc 42.6.2 读 timestamp without time zone 时把裸墙钟当成 UTC 瞬间,
+--       写回时又按会话时区(+08)归化,于是每「读出 → updateById 写回」一次
+--       时间列后移 8 小时。修复见 common/handler/ShanghaiOffsetDateTimeTypeHandler.java。
+--
+-- 漂移量恒为 8 小时的**整数倍** n。全库 n 跨 1..277(见 §6),
+-- 最大的非整数余数 1.2 微秒(浮点噪声),无负漂移,无「不足一档」的行。
+--
+-- ⚠️ 两类列**不在**本文件的漂移家族里,对它们套用 −8h 会毁数据:
+--   `timestamp with time zone` 列(pay_bill.*、pay_lsy_*.*、sys_invitation_code.*、
+--   sys_user.last_login)。它们的读数会随会话时区整体平移,
+--   §7 的正对照就是拿这个把 pay_bill 判出家族的。
+--   ⇒ 本文件 §5 的生成器已按 data_type 过滤,别再手工把 timestamptz 表加回去。
+--
+-- ---------------------------------------------------------------------------
+-- ⚠️ 两条踩过的坑,别再踩
+--
+-- 坑 1 —— 雪花公式。必须**先加毫秒纪元、再除 1000**:
+--         to_timestamp(((id >> 22) + 1288834974657) / 1000.0)
+--     写成 (id >> 22) / 1000.0 + 1288834974657 是把「毫秒」加到「秒」上,
+--     结果整体偏移约 357,651,705 小时(≈ 4 万年),
+--     所有绝对日期变成 42800 年,且「漂移行数」会对每张表都等于总行数
+--     ——那不是信号,是参照物本身坏了造成的全量假阳性。
+--     本文件 §0 就是为了先把公式钉死。
+--
+-- 坑 2 —— 别把整表拖回来。pay_transfer 有 22719 行 / 33 MB,
+--     一条不带 LIMIT 的 SELECT ... ORDER BY id DESC 就足以压断 SSH 隧道。
+--     统计一律在服务端 GROUP BY 完再回;逐行清单一律带 LIMIT。
+--     下面的语句都按这个规矩写。
+--
+-- ---------------------------------------------------------------------------
+-- 两个互相独立的参照物(互校,任一对不上就说明结论不可信):
+--   ref_snow  = 雪花 id 反解出的真实生成瞬间
+--   ref_retry = next_retry_at - 5min
+--               (该列只在插入时写过一次,之后四条更新路径虽都 setNextRetryAt(null),
+--                 但 MyBatis-Plus 默认 NOT_NULL 策略把 null 排除出 SET 子句,
+--                 所以它从插入之后再没被改过,保留了正确的原值)
+--
+-- 注意 id 与 out_biz_no 不是同一个 id 空间,只有 id 是雪花。
+-- 库中另有 21834 行是**旧序号 id**(id 1..21843,创建于 2026-04-22 ~ 05-26,
+-- 早于雪花启用),雪花公式对它们无意义,所以所有统计都带 id > 1e18 过滤。
+-- ============================================================================
+
+
+-- ---------------------------------------------------------------------------
+-- §0 公式自检:跑完先肉眼核对年月日
+--     ref_snow 应与 created_time 同日同时刻(未漂移)或恰好晚 8/16 小时。
+-- ---------------------------------------------------------------------------
+SELECT id,
+       created_time,
+       (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai') AS ref_snow,
+       round(extract(epoch FROM (created_time
+             - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))::numeric, 6) AS drift_sec
+  FROM pay_transfer
+ WHERE id > 1000000000000000000
+ ORDER BY id DESC
+ LIMIT 8;
+
+
+-- ---------------------------------------------------------------------------
+-- §1 漂移秒数精确分布 —— 这条是权威读数,先定容差再谈别的
+--     实测(**仅 pay_transfer**):只有 0 / 28800 / 57600 三个值,无小数,
+--     即 n ∈ {0,1,2}。别把这个结论推广到全库 —— 全库 n 跨 1..277(见 §6)。
+-- ---------------------------------------------------------------------------
+SELECT round(extract(epoch FROM (created_time
+             - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai'))))::bigint AS drift_sec,
+       count(*)                       AS rows,
+       count(DISTINCT status)         AS 状态数,
+       min(retry_count)               AS rt_min,
+       max(retry_count)               AS rt_max
+  FROM pay_transfer
+ WHERE id > 1000000000000000000
+ GROUP BY 1
+ ORDER BY rows DESC;
+
+
+-- ---------------------------------------------------------------------------
+-- §2 两参照物互校 —— 列绝对偏差最大的 10 行
+--     实测最大偏差 0.155 秒,两个独立来源吻合,§1 的结论才可信。
+-- ---------------------------------------------------------------------------
+SELECT id,
+       next_retry_at,
+       (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai') AS ref_snow,
+       round(extract(epoch FROM (next_retry_at - interval '5 minutes'
+             - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))::numeric, 3) AS diff_sec
+  FROM pay_transfer
+ WHERE id > 1000000000000000000
+   AND next_retry_at IS NOT NULL
+ ORDER BY abs(extract(epoch FROM (next_retry_at - interval '5 minutes'
+             - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) DESC
+ LIMIT 10;
+
+
+-- ---------------------------------------------------------------------------
+-- §3 总量(容差 60s —— 信号是 8h = 28800s,容差比信号小 480 倍)
+--     实测(**仅 pay_transfer**):885 行雪花数据中 635 未漂移、35 漂 8h、215 漂 16h、0 负漂移。
+--     注意分档名只到「16h档」为止 —— 那是 pay_transfer 里 n 最大只到 2 的缘故,
+--     别的表有 n=277 的行,会全部落进「16h档」这一格。
+-- ---------------------------------------------------------------------------
+SELECT count(*)                                                                   AS total,
+       count(*) FILTER (WHERE abs(d) < 60)                                        AS "未漂移",
+       count(*) FILTER (WHERE d >=  60 AND d < 8*3600)                            AS "0~8h",
+       count(*) FILTER (WHERE d >=  8*3600 AND d < 16*3600)                       AS "8h档",
+       count(*) FILTER (WHERE d >= 16*3600)                                       AS "16h档",
+       count(*) FILTER (WHERE d <= -60)                                           AS "负漂移"
+  FROM (SELECT extract(epoch FROM (created_time
+                 - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai'))) AS d
+          FROM pay_transfer
+         WHERE id > 1000000000000000000) x;
+
+
+-- ---------------------------------------------------------------------------
+-- §4 受损逐行清单(只列真漂了的,务必带 LIMIT)
+-- ---------------------------------------------------------------------------
+SELECT id, status, retry_count,
+       round(amount, 2)                                     AS amount,
+       created_time                                         AS "库里created_time",
+       (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai') AS "真实创建(北京)",
+       round(extract(epoch FROM (created_time
+             - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai'))) / 3600.0, 2) AS "漂移h"
+  FROM pay_transfer
+ WHERE id > 1000000000000000000
+   AND abs(extract(epoch FROM (created_time
+             - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60
+ ORDER BY id DESC
+ LIMIT 200;
+
+
+-- ---------------------------------------------------------------------------
+-- §5 全库范围:哪些表也踩了同一个坑
+--
+-- 不要用 DO 块逐表扫 —— 那是一个长语句,又慢又不好中断。
+-- 分两步:下面的查询只读系统目录,瞬时返回 56 行**可直接复制的 SQL 文本**,
+-- 逐条粘出去跑。每条都是小查询,隧道断了也只丢一条。
+-- ---------------------------------------------------------------------------
+SELECT format(
+    'SELECT %L AS tbl, count(*) FILTER (WHERE id > 1000000000000000000) AS n_snow, '
+    'count(*) FILTER (WHERE id > 1000000000000000000 AND abs(extract(epoch FROM (created_time '
+    '- (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE ''Asia/Shanghai'')))) >= 60) AS n_bad '
+    'FROM %I;',
+    c.table_name, c.table_name) AS run_me
+  FROM information_schema.columns c
+ WHERE c.table_schema = 'public'
+   AND c.column_name  = 'created_time'
+   AND c.data_type    = 'timestamp without time zone'   -- ★ 必须:timestamptz 表套这个公式只会造假象
+   AND EXISTS (SELECT 1 FROM information_schema.columns k
+                WHERE k.table_schema = 'public'
+                  AND k.table_name   = c.table_name
+                  AND k.column_name  = 'id')
+ ORDER BY c.table_name;
+
+-- 2026-09-22 重测(每个数字都当场重跑过,不引用上一轮结论):
+--   表名                       雪花行   未漂移   漂移行
+--   pay_transfer                 885      635      250
+--   pay_employee                  32       16       16
+--   pay_expense_quota             35       22       13
+--   pay_batch_order               12        0       12
+--   pay_batch_detail              12        1       11
+--   pay_enterprise                17        9        8
+--   pay_account                    7        2        5
+--   pay_f2f_trade                  5        1        4
+--   pay_batch_authorize            4        1        3
+--   pay_facetoface_order           3        0        3
+--   pay_expense_institution       12       10        2
+--   open_conf                      2        1        1
+--   ------------------------------------------------------
+--   小计                       1026      698      328   ← 可修的只有这 328 行
+--
+--   另有 4 张表有雪花 id 但**读数恰好全为 0**,留在表里只为说明扫过它们:
+--   open_transfer 18/18/0、pay_alipay_notify_log 2545/2545/0、
+--   pay_deposit 46/46/0、pay_expense_rule 12/12/0。
+--
+-- ★ 上一轮本表里 pay_bill 527/517/10 与 pay_lsy_order 4/4/0 两行是**假象**,已删。
+--   这两张表的 created_time 是 timestamptz,本公式对它们无意义:
+--   pay_bill 那「10 行漂移」把会话时区切成 UTC 就变成 523 行(见 §7 正对照)。
+--   上一轮合计 338 行 / 18 张表,减去这两张表后 = 328 行 / 16 张表。
+
+
+-- ---------------------------------------------------------------------------
+-- §6 漂移倍数 n 的分布 —— n = 漂移秒 / 28800
+--
+--     实测:n 恒为整数(最大非整数余数 1.2 微秒 = 浮点噪声),跨 1..277,
+--     无负漂移,无「不足一档」的行(|漂移| < 28800 的行数为 0)。
+--     行数合计 328,与 §5 小计一致 —— 两个独立口径对上,§5 的表才可信。
+-- ---------------------------------------------------------------------------
+WITH d AS (
+    SELECT 'pay_transfer' AS tbl, id, created_time FROM pay_transfer WHERE id > 1e18
+    UNION ALL SELECT 'pay_employee', id, created_time FROM pay_employee WHERE id > 1e18
+    UNION ALL SELECT 'pay_expense_quota', id, created_time FROM pay_expense_quota WHERE id > 1e18
+    UNION ALL SELECT 'pay_batch_order', id, created_time FROM pay_batch_order WHERE id > 1e18
+    UNION ALL SELECT 'pay_batch_detail', id, created_time FROM pay_batch_detail WHERE id > 1e18
+    UNION ALL SELECT 'pay_enterprise', id, created_time FROM pay_enterprise WHERE id > 1e18
+    UNION ALL SELECT 'pay_account', id, created_time FROM pay_account WHERE id > 1e18
+    UNION ALL SELECT 'pay_f2f_trade', id, created_time FROM pay_f2f_trade WHERE id > 1e18
+    UNION ALL SELECT 'pay_batch_authorize', id, created_time FROM pay_batch_authorize WHERE id > 1e18
+    UNION ALL SELECT 'pay_facetoface_order', id, created_time FROM pay_facetoface_order WHERE id > 1e18
+    UNION ALL SELECT 'pay_expense_institution', id, created_time FROM pay_expense_institution WHERE id > 1e18
+    UNION ALL SELECT 'open_conf', id, created_time FROM open_conf WHERE id > 1e18
+), m AS (
+    SELECT tbl, id,
+           extract(epoch FROM (created_time
+                 - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai'))) AS drift
+      FROM d
+)
+SELECT round(drift / 28800.0)::int                                   AS n,
+       count(*)                                                      AS rows,
+       count(DISTINCT tbl)                                           AS 表数,
+       string_agg(DISTINCT tbl, ',' ORDER BY tbl)                    AS 表,
+       round(max(abs(drift / 28800.0 - round(drift / 28800.0)))::numeric, 9) AS 该组最大非整数余数
+  FROM m
+ WHERE abs(drift) >= 60
+ GROUP BY 1 ORDER BY 2 DESC;
+
+-- 实测:
+--   n=2 → 248 行(10 表)   n=1 → 61 行(9 表)   n=3 → 4 行(3 表)   n=6 → 4 行(3 表)
+--   n=5 → 3 行(3 表)      n=82 → 2 行         n=277/4/7/8/15/181 各 1 行
+--   合计 328 行,最大非整数余数 0.0000012 秒
+
+
+-- ---------------------------------------------------------------------------
+-- §7 正对照:证明 timestamptz 表不在这条船上
+--
+--     同一个查询,只换**会话时区**,读数就整体平移 —— 说明读到的是会话时区造的假象,
+--     不是数据本身漂了。这个对照是本文件里唯一能证伪「某表有漂移」的手段,别省。
+-- ---------------------------------------------------------------------------
+SET TIME ZONE 'Asia/Shanghai';
+SELECT 'Asia/Shanghai' AS 会话时区, count(*) AS pay_bill漂移行
+  FROM pay_bill
+ WHERE id > 1e18
+   AND abs(extract(epoch FROM (created_time
+         - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60;
+
+SET TIME ZONE 'UTC';
+SELECT 'UTC' AS 会话时区, count(*) AS pay_bill漂移行
+  FROM pay_bill
+ WHERE id > 1e18
+   AND abs(extract(epoch FROM (created_time
+         - (to_timestamp(((id >> 22) + 1288834974657) / 1000.0) AT TIME ZONE 'Asia/Shanghai')))) >= 60;
+
+SET TIME ZONE 'Asia/Shanghai';   -- 收尾还原,别把会话留在 UTC 上跑后面的语句
+-- 实测:+08 时 10 行,UTC 时 523 行。两个读数不同 ⇒ pay_bill 的「漂移」是假象。

+ 75 - 0
java/src/test/java/com/payment/platform/common/config/DruidSessionTimeZoneConfigTest.java

@@ -0,0 +1,75 @@
+package com.payment.platform.common.config;
+
+import com.alibaba.druid.pool.DruidDataSource;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.context.properties.bind.Bindable;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
+import org.springframework.boot.env.YamlPropertySourceLoader;
+import org.springframework.core.env.MutablePropertySources;
+import org.springframework.core.env.PropertySource;
+import org.springframework.core.io.ClassPathResource;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 钉住 {@code application.yml} 里把数据库会话时区钉成北京时间的那一行。
+ *
+ * <p><b>为什么值得单独立一条测试</b>:这个属性名写错<b>不会报任何错</b>。
+ * 拼错的键被静默忽略,连接池照常工作、应用照常启动,只有那 4 个
+ * {@code timestamp with time zone} 列
+ * ({@code sys_invitation_code.used_time / created_time / updated_time}、{@code sys_user.last_login})
+ * 会在某个不确定的时刻开始读出错误的墙钟。这类「静默失效」只能靠绑定测试拦住。
+ *
+ * <p><b>本测试不连数据库</b>:只把 {@code application.yml} 绑定到 {@link DruidDataSource}
+ * 的属性上。Druid 要到 {@code init()} 才建连,纯绑定不触发。
+ */
+class DruidSessionTimeZoneConfigTest {
+
+    /** 全库 4 个 timestamptz 列的读值全靠这一条。 */
+    private static final String PIN_SESSION_TIME_ZONE = "SET TIME ZONE 'Asia/Shanghai'";
+
+    private static DruidDataSource bindDruidBlock(String prefix) throws IOException {
+        List<PropertySource<?>> loaded = new YamlPropertySourceLoader()
+                .load("application.yml", new ClassPathResource("application.yml"));
+        MutablePropertySources sources = new MutablePropertySources();
+        loaded.forEach(sources::addLast);
+        return new Binder(ConfigurationPropertySources.from(sources))
+                .bind(prefix, Bindable.of(DruidDataSource.class))
+                .orElseGet(DruidDataSource::new);
+    }
+
+    /**
+     * application.yml 必须把会话时区钉成 Asia/Shanghai。
+     *
+     * <p>断言写成「整份清单精确相等」而不是 contains:若有人在这条之后再加一条
+     * {@code SET TIME ZONE 'UTC'},contains 会放行,而实际行为已经坏了。
+     */
+    @Test
+    void applicationYmlPinsSessionTimeZone() throws IOException {
+        DruidDataSource ds = bindDruidBlock("spring.datasource.druid");
+
+        assertEquals(List.of(PIN_SESSION_TIME_ZONE), List.copyOf(ds.getConnectionInitSqls()),
+                "application.yml 必须把会话时区钉成 Asia/Shanghai —— "
+                        + "sys_invitation_code 与 sys_user.last_login 这 4 个 timestamptz 列的读值全靠它");
+    }
+
+    /**
+     * 阳性对照:属性名少写一个 {@code s} 时集合为空。
+     *
+     * <p>它证明上一条断言<b>不是恒真</b>的 —— 也就是说,若有人在 yml 里把键名拼错,
+     * 上一条会真的红。没有这一条,「集合里有一条 SQL」这个断言无法排除
+     * 「其实什么都没绑上、只是 Druid 默认如此」。
+     */
+    @Test
+    void aTypoInThePropertyNameBindsNothing() throws IOException {
+        DruidDataSource ds = bindDruidBlock("spring.datasource.druid.connection-init-sql");
+
+        assertTrue(ds.getConnectionInitSqls().isEmpty(),
+                "对照读数:键名拼错时集合必须是空的(不报错、静默失效)");
+    }
+}

+ 481 - 0
java/src/test/java/com/payment/platform/common/handler/ShanghaiOffsetDateTimeTypeHandlerTest.java

@@ -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 的默认处理器必须已被替换,否则修复不生效");
+    }
+}