mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-08 22:21:09 +00:00
## 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
245 lines
6.9 KiB
Markdown
245 lines
6.9 KiB
Markdown
# 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 4:API 契约收口
|
||
|
||
执行内容:
|
||
|
||
- 普通 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 解释假设
|