fix(api_push): naive published_at 改当 Asia/Shanghai,不再当 UTC

症状: 财联社/微信等中国源推 ingest 时,payload published_at 通常不带 tz
(naive 字符串),含义是北京时间的墙上时间。
后端之前用 .replace(tzinfo=timezone.utc) 强行当 UTC,导致入库后:
  '2026-06-15 19:58:42' (naive,真意=北京 19:58) →
  PG 存 2026-06-15 19:58:42+00 = 渲染成 2026-06-16 03:58:42+08

前端 dayjs(...).fromNow() 算到当前 +08 时间 20:55 → 显示 '7 小时内'(未来时间)

根因: 中国源给的 naive 字符串实际就是 Asia/Shanghai,不该被当 UTC

修法:
- 新增 zoneinfo + settings.tz 依赖
- naive → settings.tz(默认 Asia/Shanghai),再 astimezone(UTC) 入库
- aware → astimezone(UTC) 归一

注意: 只修未来的新数据;已入库的错位数据需要 backfill(见 PR 后讨论)
This commit is contained in:
xiaji
2026-06-15 21:01:45 +08:00
parent 6b087113e9
commit 5eb331bf2d

View File

@@ -17,6 +17,9 @@ from __future__ import annotations
import hashlib import hashlib
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from zoneinfo import ZoneInfo
from app.config import settings
def compute_content_hash( def compute_content_hash(
@@ -47,26 +50,39 @@ def synthesize_url(source_slug: str, content_hash: str) -> str:
def normalize_published_at(value: Any) -> datetime: def normalize_published_at(value: Any) -> datetime:
"""published_at 兜底:无值 → now(UTC)。""" """published_at 兜底:无值 → now(本地时区)。
⚠️ 关于 naive datetime 的时区推断(2026-06-15 fix):
- 财联社/微信/微博 这类**中国源**通过 ingest 推送时,通常传 naive 字符串
(不带 tz 后缀),默认就是 Asia/Shanghai 的"墙上时间"
- 之前的实现把 naive 强加 UTC,导致所有"中国源"新闻 published_at 错位 8 小时
(财联社 19:58 被存为 19:58 UTC = 03:58 +08,前端显示"7 小时内"未来时间)
- 修法:naive 当服务器 settings.tz(默认 Asia/Shanghai)处理,转 UTC 入库
- aware 原样转 UTC 归一
入库统一存 UTC(aware),渲染时按调用方 tz(默认 +08)显示。
"""
server_tz = ZoneInfo(settings.tz)
def _to_utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
# naive → 服务器 tz(默认 Asia/Shanghai),再转 UTC
dt = dt.replace(tzinfo=server_tz)
return dt.astimezone(timezone.utc)
if value is None: if value is None:
return datetime.now(timezone.utc) return datetime.now(server_tz).astimezone(timezone.utc)
if isinstance(value, datetime): if isinstance(value, datetime):
# 入参可能是 naive datetime(没带 tz),按 UTC 兜底 return _to_utc(value)
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
if isinstance(value, str): if isinstance(value, str):
# ISO8601 解析;失败的让 pydantic 在路由层报 400
try: try:
# fromisoformat 在 3.11+ 支持 'Z' 后缀;3.12 没问题 # fromisoformat 在 3.11+ 支持 'Z' 后缀;3.12 没问题
dt = datetime.fromisoformat(value.replace("Z", "+00:00")) dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
if dt.tzinfo is None: return _to_utc(dt)
dt = dt.replace(tzinfo=timezone.utc)
return dt
except ValueError: except ValueError:
# 解析失败兜底为 now;路由层校验会先于 normalize 跑,pydantic 应该已经报 400 了 # 解析失败兜底为 now;路由层校验会先于 normalize 跑,pydantic 应该已经报 400 了
return datetime.now(timezone.utc) return datetime.now(server_tz).astimezone(timezone.utc)
return datetime.now(timezone.utc) return datetime.now(server_tz).astimezone(timezone.utc)
def build_initial_status(*, has_tags: bool) -> dict[str, str]: def build_initial_status(*, has_tags: bool) -> dict[str, str]: