Files
live-hub-py/tests/test_utc_datetime.py
T
yml2213 65a4590291 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 负责转本地。
2026-10-02 20:27:07 +08:00

95 lines
4.1 KiB
Python
Raw 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.
"""回归:时间列统一走 ``UTCDateTime`` —— 读出来必须带时区,写进去必须存 UTC。
背景:库里存的是 naive UTC,读出来也是裸时间,于是每一处比较/输出都要自己记得
补时区。项目里因此散了三份 ``_as_utc`` 和一堆 ``replace(tzinfo=UTC)``,还踩过一次
TypeError: can't compare offset-naive and offset-aware datetimes
(P1a 的 CK 巡检排下一轮时间时炸的)。现在把边界收在 ``UTCDateTime``:
写时 aware→UTC 裸值、naive 视为 UTC;读时一律补 UTC 时区。
这组测试盯三件事:读出来是 aware、能和 ``datetime.now(UTC)`` 直接比、写进去
落的是 UTC 而不是本地时间。
"""
from datetime import UTC, datetime, timedelta, timezone
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from web.backend.database import Base, UTCDateTime
from web.backend.models import AutomationRun, LoginTask
class TestUtcDateTime:
def setup_method(self):
self.engine = create_engine("sqlite://")
Base.metadata.create_all(self.engine)
self.session = sessionmaker(bind=self.engine)()
def teardown_method(self):
self.session.close()
def _row(self, started_at):
self.session.add(
AutomationRun(job_key="k", started_at=started_at, status="success")
)
self.session.commit()
return self.session.query(AutomationRun).one()
def test_read_back_is_timezone_aware(self):
row = self._row(datetime(2026, 10, 2, 12, 0, tzinfo=UTC))
assert row.started_at.tzinfo is not None
assert row.started_at.utcoffset() == timedelta(0)
def test_read_back_value_can_compare_with_now(self):
"""这条就是当初炸掉的写法:库里的时间直接和 datetime.now(UTC) 比。"""
row = self._row(datetime.now(UTC) - timedelta(minutes=1))
assert row.started_at < datetime.now(UTC) # 不再 TypeError
def test_write_converts_local_aware_to_utc(self):
"""带 +08:00 的时间要按 UTC 落库,不能把本地壁钟直接存进去。"""
local = datetime(2026, 10, 2, 20, 0, tzinfo=timezone(timedelta(hours=8)))
row = self._row(local)
assert row.started_at == datetime(2026, 10, 2, 12, 0, tzinfo=UTC)
raw = self.session.execute(
text("SELECT started_at FROM automation_runs")
).scalar()
assert str(raw).startswith("2026-10-02 12:00")
def test_naive_write_is_treated_as_utc(self):
"""库里读出来的裸值再写回去(常见于搬迁/复制)不能被当成本地时间再减 8 小时。"""
naive_utc = datetime(2026, 10, 2, 12, 0) # noqa: DTZ001 刻意构造裸 UTC
row = self._row(naive_utc)
assert row.started_at == datetime(2026, 10, 2, 12, 0, tzinfo=UTC)
def test_none_stays_none(self):
assert UTCDateTime().process_bind_param(None, None) is None
assert UTCDateTime().process_result_value(None, None) is None
def test_all_model_datetime_columns_use_utc_type(self):
"""新增时间列若忘了用 UTCDateTime,这里直接失败。"""
offenders = []
for table in Base.metadata.sorted_tables:
for column in table.columns:
if isinstance(column.type, UTCDateTime):
continue
if str(column.type).upper().startswith("DATETIME"):
offenders.append(f"{table.name}.{column.name}")
assert not offenders, f"这些时间列没用 UTCDateTime: {offenders}"
def test_login_task_attempt_at_round_trips_as_utc(self):
"""真实模型抽查:写 aware、读 aware、能直接比较。"""
task = LoginTask(
batch_id="b",
account_id=1,
created_by=1,
attempt_at=datetime(2026, 10, 2, 12, 0, tzinfo=UTC),
)
self.session.add(task)
self.session.commit()
self.session.refresh(task)
assert task.attempt_at == datetime(2026, 10, 2, 12, 0, tzinfo=UTC)
# 模型侧保证 aware,所以业务代码可以直接比较
assert task.attempt_at < datetime.now(UTC)