refactor(time): 时间列统一走 UTCDateTime,并把 MySQL 会话时区对齐成 UTC

问题不是「UTC 还是本地时间」,而是这套口径有两个半吊子的地方:

1. 库里存 naive UTC、读出来也是裸时间,于是每一处比较/输出都得自己补时区。
   项目里因此散了三份 _as_utc 和一堆 replace(tzinfo=UTC),P1a 排下一轮时间
   时还炸过一次 TypeError: can't compare offset-naive and offset-aware。
2. MySQL 会话时区跟随系统 +08:00,而库里存的是 UTC —— SQL 里的 NOW()/
   CURDATE() 比库里的时间快 8 小时,查询会静默返回空(P1a 排查时就遇到过,
   看着像「没有数据」)。

改动:

- 新增 database.UTCDateTime:写时 aware→转 UTC 去 tzinfo、naive 视为 UTC;
  读时一律补 UTC 时区。底层 DDL 仍是 DATETIME,**不需要迁移**。models.py 的
  28 个时间列全部改用它,模型上的时间字段从此不再是裸值;
- docker-compose 给 mysql 加 --default-time-zone=+00:00,让 SQL 时间函数与存储
  口径一致。安全性已核实:全库 0 个 TIMESTAMP 列、0 个 CURRENT_TIMESTAMP
  默认值,改这里不会改变任何已有数据的语义;
- 新增 tests/test_utc_datetime.py:读出必须 aware、能直接和 datetime.now(UTC)
  比较、带 +08:00 的写入要落成 UTC、naive 写入视为 UTC,并有一条元测试扫描
  Base.metadata,防止以后新增时间列忘了用 UTCDateTime。

保留既有的 _as_utc 补丁(对 aware 值是幂等的,属防御性代码);新代码不再需要。
「存 UTC、算 UTC、显示本地」三条线至此各自明确:存储与比较统一 UTC,日志按
容器 TZ 显示本地时间,前端 formatTime 负责转本地。
This commit is contained in:
yml2213
2026-10-02 20:27:07 +08:00
parent 3d5d0f551f
commit 65a4590291
5 changed files with 188 additions and 48 deletions
+38 -1
View File
@@ -1,11 +1,13 @@
"""数据库引擎与会话管理"""
import os
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import quote_plus
from sqlalchemy import create_engine, event
from sqlalchemy import DateTime, create_engine, event
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.types import TypeDecorator
PROJECT_ROOT = Path(__file__).resolve().parents[2]
@@ -86,6 +88,41 @@ SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
Base = declarative_base()
class UTCDateTime(TypeDecorator):
"""时间列统一走这里:**库里存 naive UTC,Python 侧永远是 tz-aware UTC**。
之前是全靠约定:写入用 ``datetime.now(UTC)``(MySQL 绑定参数会丢掉 tzinfo,
落库成 UTC 裸值),读出是裸值,谁要比较或输出就得自己记得补时区 —— 项目里
因此散了三份 ``_as_utc`` 和一堆 ``replace(tzinfo=UTC)``,还踩过一次
``TypeError: can't compare offset-naive and offset-aware datetimes``。
这个装饰器把边界收在一处:
- 写:aware → 转 UTC 后去掉 tzinfo;naive → 视为已是 UTC,原样存;
- 读:一律补上 UTC 时区。
于是模型上的时间字段永远不会是裸时间,比较、相减、序列化都不必再各自处理。
底层 DDL 仍是 DATETIME(``impl`` 不变),**不需要迁移**。
"""
impl = DateTime
cache_ok = True
def process_bind_param(self, value: datetime | None, dialect):
if value is None:
return None
if value.tzinfo is None:
return value
return value.astimezone(UTC).replace(tzinfo=None)
def process_result_value(self, value: datetime | None, dialect):
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def get_db():
"""FastAPI 依赖:提供数据库会话,请求结束自动关闭。"""
db = SessionLocal()