-- ============================================================================ -- TamaBox 时间列 timestamptz 迁移脚本(线上执行版) -- 前置: 本脚本已在你提供的生产克隆库(nekobox_clone)完整验证通过: -- * 迁移后 epoch 与迁移前逐项一致(差异 0) -- * 回滚路径同样验证无损(可安全回退) -- * 会话时区无关(验证时使用 Asia/Shanghai 会话) -- 实际受影响表(含数据): questions / users / censor_logs / upload_images -- 其余表(conversations/messages/votes/...)时间列本来就是 timestamptz, 脚本会自动跳过 -- 覆盖: 只读 + ALTER(DDL), 建议低峰窗口执行; 克隆实测全库 1.7s 完成 -- 使用: psql/pgAdmin 整段按 PART 顺序执行, 把输出贴回核对 -- 注意: 迁移期间避免应用滚动发布; 执行前确认无长时间运行事务持锁 -- ============================================================================ -- ============================================================================ -- PART 0 前置核对: 列出将被迁移的列(线上应为 4 表 x 3 列 = 12 列) -- ============================================================================ SELECT 'PART 0: 将被迁移的无时区时间列' AS step; SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'nekobox' AND data_type = 'timestamp without time zone' AND column_name IN ('created_at','updated_at','deleted_at') ORDER BY table_name, column_name; -- ============================================================================ -- PART 1 迁移前 epoch 快照(存入 diag_test schema, 供 PART 3 自动比对) -- ============================================================================ SELECT 'PART 1: 迁移前快照' AS step; CREATE SCHEMA IF NOT EXISTS diag_test; DROP TABLE IF EXISTS diag_test.snap_before; CREATE TABLE diag_test.snap_before(tbl text, col text, n bigint, minv numeric, maxv numeric, s numeric); DO $$ DECLARE r record; BEGIN FOR r IN SELECT table_name tbl, column_name col FROM information_schema.columns WHERE table_schema='nekobox' AND data_type='timestamp without time zone' AND column_name IN ('created_at','updated_at','deleted_at') ORDER BY table_name, column_name LOOP EXECUTE format( 'INSERT INTO diag_test.snap_before SELECT %L, %L, count(%I), min(extract(epoch FROM %I)*1000000000), max(extract(epoch FROM %I)*1000000000), sum(extract(epoch FROM %I)*1000000000) FROM nekobox.%I', r.tbl, r.col, r.col, r.col, r.col, r.col, r.tbl); END LOOP; END $$; SELECT '快照完成, 共 ' || count(*) || ' 列' AS info FROM diag_test.snap_before; -- ============================================================================ -- PART 2 执行迁移 -- 核心语义: 库存值是 UTC 墙钟, 用 AT TIME ZONE 'UTC' 显式解释为 timestamptz, -- 与会话时区无关。绝不可用裸 ALTER(会按会话时区解释 -> 全部 -8h 偏移)。 -- ============================================================================ SELECT 'PART 2: 执行迁移' AS step; DO $$ DECLARE r record; BEGIN FOR r IN SELECT table_name tbl, column_name col FROM information_schema.columns WHERE table_schema='nekobox' AND data_type='timestamp without time zone' AND column_name IN ('created_at','updated_at','deleted_at') ORDER BY table_name, column_name LOOP EXECUTE format( 'ALTER TABLE nekobox.%I ALTER COLUMN %I TYPE timestamptz USING %I AT TIME ZONE ''UTC''', r.tbl, r.col, r.col); END LOOP; END $$; SELECT '迁移完成' AS step; -- ============================================================================ -- PART 3 验收(4 项全绿 = 迁移成功; 任一项不满足立即执行 PART 4 回滚) -- ============================================================================ SELECT 'PART 3: 验收' AS step; -- 3.1 迁移后快照并自动比对: 差异应为 0 行 DROP TABLE IF EXISTS diag_test.snap_after; CREATE TABLE diag_test.snap_after(tbl text, col text, n bigint, minv numeric, maxv numeric, s numeric); DO $$ DECLARE r record; BEGIN FOR r IN SELECT table_name tbl, column_name col FROM information_schema.columns WHERE table_schema='nekobox' AND data_type='timestamp with time zone' AND column_name IN ('created_at','updated_at','deleted_at') ORDER BY table_name, column_name LOOP EXECUTE format( 'INSERT INTO diag_test.snap_after SELECT %L, %L, count(%I), min(extract(epoch FROM %I)*1000000000), max(extract(epoch FROM %I)*1000000000), sum(extract(epoch FROM %I)*1000000000) FROM nekobox.%I', r.tbl, r.col, r.col, r.col, r.col, r.col, r.tbl); END LOOP; END $$; SELECT '3.1 迁移前后 epoch 差异(应为 0 行)' AS check_name; SELECT b.tbl, b.col, b.n AS n_before, a.n AS n_after, b.minv AS min_before, a.minv AS min_after, b.maxv AS max_before, a.maxv AS max_after, b.s AS sum_before, a.s AS sum_after FROM diag_test.snap_before b JOIN diag_test.snap_after a USING (tbl, col) WHERE b.n IS DISTINCT FROM a.n OR b.minv IS DISTINCT FROM a.minv OR b.maxv IS DISTINCT FROM a.maxv OR b.s IS DISTINCT FROM a.s; -- 3.2 残余无时区时间列(应为 0) SELECT '3.2 残余 timestamp without time zone(应 0)' AS check_name; SELECT count(*) AS remain_without FROM information_schema.columns WHERE table_schema='nekobox' AND data_type='timestamp without time zone' AND column_name IN ('created_at','updated_at','deleted_at'); -- 3.3 关键行 epoch 指纹(应与迁移前/线上游标一致) SELECT '3.3 关键行 epoch 指纹' AS check_name; SELECT id, (extract(epoch FROM created_at)*1000000000)::numeric(30,0) AS epoch_ns FROM nekobox.questions WHERE id IN (3109, 3089, 2411) ORDER BY id; -- 3.4 keyset 翻页抽查: 应精确返回 3088,2883,...,2411 共 18 条(与修复后线上第2页一致) SELECT '3.4 keyset 翻页抽查(第2页)' AS check_name; SELECT q.id FROM nekobox.questions q WHERE q.user_id = (SELECT id FROM nekobox.users WHERE domain='aoi') AND q.deleted_at IS NULL AND q.answer <> '' AND q.is_private = false AND q.is_pinned = false AND (q.created_at < TIMESTAMPTZ '2026-09-05 00:01:16.678885+00' OR (q.created_at = TIMESTAMPTZ '2026-09-05 00:01:16.678885+00' AND q.id < 3089)) ORDER BY q.is_pinned DESC, q.created_at DESC, q.id DESC; -- ============================================================================ -- PART 4 回滚(仅在 PART 3 验收不通过或线上出现异常时执行) -- 还原为 timestamp, 与迁移前快照一致(已在克隆库验证无损) -- ============================================================================ -- SELECT 'PART 4: 回滚(按需执行)' AS step; -- DO $$ -- DECLARE r record; -- BEGIN -- FOR r IN -- SELECT table_name tbl, column_name col -- FROM information_schema.columns -- WHERE table_schema='nekobox' AND data_type='timestamp with time zone' -- AND column_name IN ('created_at','updated_at','deleted_at') -- AND table_name IN ('questions','users','censor_logs','upload_images') -- ORDER BY table_name, column_name -- LOOP -- EXECUTE format( -- 'ALTER TABLE nekobox.%I ALTER COLUMN %I TYPE timestamp USING %I AT TIME ZONE ''UTC''', -- r.tbl, r.col, r.col); -- END LOOP; -- END $$; -- SELECT '回滚完成(可再次跑 PART 3 核验)' AS step; -- ============================================================================ -- 验收说明: -- 3.1 = 0 行且 3.2 = 0、3.3 指纹与迁移前一致、3.4 翻出 18 条 -> 迁移成功 -- 任何一项不符 -> 执行 PART 4 回滚后停止排查, 联系开发 -- 迁移后建议: -- 1) 重新部署应用(含 parsePinCursor 的 .UTC() 修复版本) -- 2) 复跑今日翻页验收脚本, 预期 38 条唯一收敛 -- 3) 确认无误后可清理 diag_test schema: DROP SCHEMA diag_test; -- ============================================================================