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

245 lines
6.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `V13``V23`
### Phase 4API 契约收口
执行内容:
- 普通 JSON API 中所有绝对时间字段统一输出 UTC 字符串
- 禁止接口返回裸 `LocalDateTime.toString()`
- 逐步淘汰无时区输入
当前状态:
- 普通 API 与后台 DTO 已基本完成 UTC 输出收口
- 剩余兼容重点是旧接口对裸时间字符串输入的处理策略
### Phase 5清理与强约束
执行内容:
- 清理遗留兼容时区假设
- 增加 ArchUnit 或静态扫描规则
- 增加跨时区测试,例如 `UTC``Asia/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 解释假设