skillhub/docs/15-backend-time-governance-plan.md
dongmucat 04caf21e76
fix(audit): resolve 8-hour timezone offset in audit log timestamps (#472)
## Problem
Audit log timestamps displayed 8 hours later than actual time when JVM
default timezone != UTC. Root cause: `audit_log.created_at` was
`TIMESTAMP without time zone`, and `rs.getTimestamp()` interprets bare
values using JVM timezone.

## Solution
### Backend
- **V42 migration**: Upgrade `audit_log.created_at` from `TIMESTAMP` to
  `TIMESTAMPTZ`, anchor historical data as UTC via `USING created_at AT
  TIME ZONE 'UTC'` (same pattern as V18/V19/V23/V25/V36)
- **Read path**: `AdminAuditLogAppService.readInstant()` uses
  `rs.getObject(col, OffsetDateTime.class).toInstant()`, result
  independent of JVM timezone
- **Write path (filter params)**: `startTime`/`endTime` binding changed
  from `Timestamp.from()` to `OffsetDateTime.ofInstant(instant,
  ZoneOffset.UTC)` via `toUtcOffsetDateTime()` helper, symmetric with
  read path

### Migration Safety
- `SET LOCAL lock_timeout = '30s'` (transaction-scoped, won't leak to pool)
- `DO $$ ... IF data_type = 'timestamp without time zone' THEN ... ELSE
  ... END $$` idempotent guard with dual-branch `RAISE NOTICE`
- Safe retry: re-running won't double-apply `AT TIME ZONE 'UTC'`

### Test Coverage (10 tests, 477 total suite)
- `rowMapper_readsCreatedAtAsInstant` — UTC offset regression
- `rowMapper_normalisesNonUtcOffsetToInstant` — Non-UTC offset (+08:00)
- `rowMapper_returnsNullTimestampWhenColumnIsNull` — Null path
- `rowMapper_isIndependentOfJvmDefaultTimezone` — JVM TZ=Asia/Shanghai
  drift prevention with `verify(rs, never()).getTimestamp()`
- `@ParameterizedTest buildWhereClause_bindsTimeRangeAsOffsetDateTime` —
  3 cases (both/startOnly/endOnly) for filter param binding
- `@BeforeEach setUp()` — Mock isolation to prevent cross-test stub
  accumulation

## Quality Gates
- [x] `make test-backend-app` passes (477 tests, 0 failures)
- [x] No Controller changes, `make generate-api` not needed
- [x] No frontend changes, typecheck/lint/e2e not needed

## Deployment
V42 must run before new code (guaranteed by Spring Boot startup sequence
→ Flyway executes before app accepts traffic). Rolling deployment:
- New pod + migrated column: correct
- Old pod + migrated column: old code reads TIMESTAMPTZ correctly (pgjdbc
  returns absolute instant)

## Related Docs
- `docs/15-backend-time-governance-plan.md` §3.1: V42 progress registered
- `docs/16-backend-time-inventory.md` §3.1: V42 listed
- Same migration pattern: V18/V19/V23/V25/V36
2026-06-05 15:28:12 +08:00

6.9 KiB
Raw Permalink Blame History

skillhub 后端日期时间治理计划

1. 当前结论

当前主系统已经基本完成 UTC 语义收口:

  • 核心业务时间字段大多已迁到 Instant
  • 核心事件时间列大多已迁到 TIMESTAMPTZ
  • 服务层“当前时间”大多已统一走注入 Clock
  • 普通 API 和后台 DTO 的绝对时间已基本统一输出 UTC ISO-8601

系统当前保留的已经不是大范围混用,而是少量兼容尾项。剩余风险主要集中在:

  • 个别旧接口仍允许无时区字符串输入
  • 新增代码如果重新引入 LocalDateTime.now(),可能把系统带回默认时区依赖
  • 缺少跨时区自动化回归时,仍可能遗漏边界问题

2. 目标

治理目标不是“所有地方都只用一种类型”,而是统一时间语义:

  • 绝对时间点:统一使用 UTC 语义Java 使用 Instant
  • 面向业务输入的本地时间:只有在需求明确要求“本地日历时间”时才允许保留 LocalDateTime
  • 数据库存储绝对时间点时,统一使用 TIMESTAMPTZ
  • 对外 API 返回绝对时间点时,统一输出 ISO-8601 UTC 字符串,例如 2026-03-18T06:30:00Z
  • 不再把没有时区语义的 LocalDateTime 继续向领域层传播

这里要明确区分:

  • i18n 解决的是语言、文案、本地化展示
  • 时间统一到 UTC 解决的是跨时区一致性

3. 目标模型

建议把后端时间字段分成三类管理:

3.1 系统事件时间

适用字段:

  • createdAt
  • updatedAt
  • publishedAt
  • submittedAt
  • reviewedAt
  • hiddenAt
  • yankedAt
  • lastUsedAt
  • revokedAt
  • readAt
  • handledAt
  • tokenExpiresAt

约束:

  • Java 类型统一为 Instant
  • 数据库列统一为 TIMESTAMPTZ
  • 读写都按 UTC 绝对时间处理

进度登记:

  • audit_log.created_at 已通过 V42 迁移到 TIMESTAMPTZ,详见 docs/16-backend-time-inventory.md §3.1

3.2 业务输入时间

适用场景:

  • 用户手工输入一个“到某天某时截止”的字段
  • 规则明确绑定某个业务时区,而不是系统时区

约束:

  • 如果该时间代表真实绝对时刻,入口就应要求带时区或明确时区来源,然后在服务层立刻转换为 Instant
  • 不允许把用户输入的裸 yyyy-MM-ddTHH:mm:ss 长期保存在核心领域模型中

3.3 纯日期字段

适用场景:

  • 生日
  • 账期
  • 结算日
  • 自然日统计

约束:

  • 使用 LocalDate
  • 不参与 UTC/时区转换

4. 现状问题

4.1 历史问题已基本清理

此前系统的主要问题包括:

  • 领域层大量使用 LocalDateTime
  • 服务层散落 LocalDateTime.now()
  • 数据库 DDL 大量使用 TIMESTAMP
  • 兼容层存在隐式 UTC 假设和冲突解释

当前这些问题在主链代码中已基本完成治理,保留它们主要是为了说明为什么迁移顺序必须先做基础设施,再做模型与数据库。

4.2 当前仍存在的实际问题

  • ApiTokenService 仍兼容裸时间字符串输入
  • 尚未建立静态约束来阻止未来重新引入 LocalDateTime.now()
  • 尚未形成系统性的跨时区回归基线

5. 治理原则

  • 先统一新增代码,再迁移存量代码
  • 先统一领域模型,再迁移数据库,再收口 API
  • 所有“当前时间”获取统一从 Clock 注入,禁止继续散落 now()
  • 迁移期间优先保证 API 兼容,避免前端和 CLI 同时破坏
  • 对外只暴露明确语义的时间格式,不暴露“无时区但又默认是 UTC”的灰色状态

6. 分阶段计划

Phase 0基线审计

产出:

  • 全量时间字段清单
  • LocalDateTime / Instant / LocalDate 使用清单
  • TIMESTAMP / TIMESTAMPTZ 列清单
  • API 请求与响应中的时间字段清单
  • 兼容层中所有 epoch 转换点清单

当前状态:

  • 已完成初版盘点
  • 已同步到当前代码真实进展

Phase 1统一规范与基础设施

执行内容:

  • 新增全局 UTC Clock
  • 配置 Hibernate JDBC 时区为 UTC
  • 配置 Jackson UTC 输出
  • 建立“绝对时间用 Instant”规范

当前状态:

  • 已完成

Phase 2代码层迁移到 Instant

执行内容:

  • 实体字段改为 Instant
  • LocalDateTime.now() 改为 Instant.now(clock)
  • 比较逻辑统一为 Instant
  • DTO 与服务同步迁移

当前状态:

  • 主链已基本完成
  • 生产代码中仅剩极少数兼容解析代码保留 LocalDateTime

Phase 3数据库迁移到 TIMESTAMPTZ

执行内容:

  • 为核心表新增 Flyway migration
  • 明确历史 TIMESTAMP 数据按 UTC 解释

当前状态:

  • 主链核心事件时间列已基本完成
  • 已落地 migration V13V23

Phase 4API 契约收口

执行内容:

  • 普通 JSON API 中所有绝对时间字段统一输出 UTC 字符串
  • 禁止接口返回裸 LocalDateTime.toString()
  • 逐步淘汰无时区输入

当前状态:

  • 普通 API 与后台 DTO 已基本完成 UTC 输出收口
  • 剩余兼容重点是旧接口对裸时间字符串输入的处理策略

Phase 5清理与强约束

执行内容:

  • 清理遗留兼容时区假设
  • 增加 ArchUnit 或静态扫描规则
  • 增加跨时区测试,例如 UTCAsia/Shanghai

当前状态:

  • 尚未完成
  • 这是下一阶段最有价值的工作

7. 重点技术决策

7.1 为什么用 Clock 而不是只用 Instant.now()

  • Instant 解决“时间如何表达”
  • Clock 解决“当前时间从哪里来”
  • 推荐组合是 Instant.now(clock)

这使服务层可测试、可固定时间、可避免机器本地时区干扰。

7.2 是否统一引入 OffsetDateTime

本项目更适合以 Instant 作为核心绝对时间类型,原因是:

  • 多数字段表达的是事件发生时刻
  • 业务侧通常不需要保留原始 offset
  • Instant 更能防止“看起来像本地时间”的误解

只有在必须保留调用方原始 offset 的场景下,才考虑 OffsetDateTime

7.3 expiresAt 这类用户输入字段怎么处理

长期目标:

  • API 约定输入为 RFC 3339 / ISO-8601 带时区时间
  • 服务层解析后立即转换为 Instant

短期兼容:

  • 旧接口若仍接受裸字符串,应在 controller 或 service 边界集中兜底
  • 必须明确记录这是兼容逻辑,而不是长期契约

8. 风险与应对

风险 应对
历史 TIMESTAMP 数据真实语义不一致 先做抽样和数据画像,必要时分批迁移
前端或 CLI 已依赖不带时区的旧格式 保留短期兼容解析,同时明确废弃计划
新代码继续引入 LocalDateTime.now() 加静态扫描和 review 规则阻断
缺少跨时区回归导致边界问题漏检 增加 UTC / Asia/Shanghai 双时区测试矩阵

9. 推荐后续顺序

  1. LocalDateTime.now() 和实体层 LocalDateTime 增加静态约束
  2. 增加跨时区回归测试
  3. 梳理并逐步淘汰裸时间字符串输入兼容
  4. 对生产历史数据做一次抽样校验,确认所有 TIMESTAMPTZ 迁移都符合 UTC 解释假设