"""时区换算:库内存 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) 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) 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)