2026-09-11 12:50:52 +08:00
|
|
|
|
"""时区换算:库内存 UTC naive,边界判断与展示按配置时区(默认北京时间)。
|
|
|
|
|
|
|
|
|
|
|
|
**为什么需要它**:风控的定时规则曾直接取 `confirmed_at.hour` 判断"凌晨",而库里存的是
|
|
|
|
|
|
UTC —— `[0,6)` UTC 实际是北京时间 **08:00–14:00**,于是「凌晨时段小额操作」这条规则把
|
|
|
|
|
|
整个上午的交易都判成了凌晨(`docs/25` P1 #6)。日报也按 UTC 日切,却按北京时间展示,
|
|
|
|
|
|
两处对同一天的理解差 8 小时。
|
|
|
|
|
|
|
|
|
|
|
|
**约定**(与 `app/infrastructure/db.py:15-21` 的说明一致):
|
|
|
|
|
|
|
|
|
|
|
|
- 库内 DATETIME 为 **UTC naive**,不带时区;
|
|
|
|
|
|
- 任何"这是本地几点 / 哪一天"的判断,都必须先经过本模块换算;
|
|
|
|
|
|
- 展示同样走这里,保证与 `get_settings().timezone` 一致。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import UTC, date, datetime, time, timedelta
|
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.config import get_settings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def local_zone() -> ZoneInfo:
|
|
|
|
|
|
"""业务判断与展示统一使用的本地时区(默认 `Asia/Shanghai`)。"""
|
|
|
|
|
|
return ZoneInfo(get_settings().timezone)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def to_local(value: datetime) -> datetime:
|
|
|
|
|
|
"""把库内的 UTC naive 时间换算成本地时区。
|
|
|
|
|
|
|
|
|
|
|
|
已经带时区的值按其原时区处理——那说明它来自库外(例如请求参数),
|
|
|
|
|
|
按它自己声明的时区解释才是对的。
|
|
|
|
|
|
"""
|
|
|
|
|
|
aware = value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
|
|
|
|
|
return aware.astimezone(local_zone())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def to_utc_naive(value: datetime) -> datetime:
|
|
|
|
|
|
"""把任意时区的时间换算回**库内格式**(UTC naive),用于构造查询条件。
|
|
|
|
|
|
|
|
|
|
|
|
查询参数必须经过这一步:拿本地时间直接去比库内的 UTC 列,会整体差 8 小时。
|
|
|
|
|
|
"""
|
|
|
|
|
|
aware = value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
|
|
|
|
|
return aware.astimezone(UTC).replace(tzinfo=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 12:55:00 +08:00
|
|
|
|
def from_local(value: datetime) -> datetime:
|
|
|
|
|
|
"""把**客户端传来的时间参数**换算成库内格式(UTC naive)。
|
|
|
|
|
|
|
|
|
|
|
|
REST 的时间参数是裸 `datetime`(`api/schemas/risk.py:32-33`),不带时区信息。
|
|
|
|
|
|
对面向中国客户的业务系统,裸值按**本地(北京)时间**解释才符合填表人的预期:
|
|
|
|
|
|
当成 UTC 会让前端填的"今天 00:00"实际查到昨天 08:00 起的数据。
|
|
|
|
|
|
带时区的值仍按它自己声明的时区处理。
|
|
|
|
|
|
|
|
|
|
|
|
与 `to_utc_naive` 的区别就在裸值上:这个把裸值当**本地**,那个当 **UTC**。
|
|
|
|
|
|
取库里的值用后者,接客户端输入用这个。
|
|
|
|
|
|
"""
|
|
|
|
|
|
aware = value if value.tzinfo is not None else value.replace(tzinfo=local_zone())
|
|
|
|
|
|
return aware.astimezone(UTC).replace(tzinfo=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 12:50:52 +08:00
|
|
|
|
def local_hour(value: datetime) -> int:
|
|
|
|
|
|
"""库内时间对应的**本地**小时(0-23)。
|
|
|
|
|
|
|
|
|
|
|
|
用于"是否凌晨"这类按时段判断的规则——绝不能用 `value.hour`,
|
|
|
|
|
|
那是 UTC 小时。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return to_local(value).hour
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def local_date(value: datetime) -> date:
|
|
|
|
|
|
"""库内时间对应的**本地**日期。"""
|
|
|
|
|
|
return to_local(value).date()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def local_day_bounds(value: datetime) -> tuple[datetime, datetime]:
|
|
|
|
|
|
"""库内时间所在的**本地自然日**,换算回库内格式的起止时刻 `[start, end)`。
|
|
|
|
|
|
|
|
|
|
|
|
返回的是 UTC naive:它要拿去查库内的 UTC 列。若返回本地时间,
|
|
|
|
|
|
区间会与库内值整体错开 8 小时——这正是原先日报日界出错的成因。
|
|
|
|
|
|
"""
|
|
|
|
|
|
local = to_local(value)
|
|
|
|
|
|
start_local = datetime.combine(local.date(), time.min, tzinfo=local.tzinfo)
|
|
|
|
|
|
start = to_utc_naive(start_local)
|
|
|
|
|
|
return start, start + timedelta(days=1)
|