fix(risk): 修正风控的时区缺陷——凌晨规则与日报日界

docs/25 P1 #6。根因是"库内存 UTC naive"这个约定在**业务判断层**没被遵守,
而展示层其实已经是对的(risk_daily_report_service.py:418 按配置时区转换)。

1. 新增 app/core/timeutil.py 作为统一换算入口:约定库内 UTC naive,提供
   local_hour / local_date / local_day_bounds(后两者用于查库时必须返回 UTC naive,
   否则区间与库内值整体错开 8 小时),带时区的入参按其自身时区解释。
2. risk_scan_service.py:248:0 <= confirmed_at.hour < 6 → local_hour(...)。
   原先 [0,6) UTC 被当成"凌晨",实际是北京时间 08:00-14:00,整条
   「凌晨时段小额操作」规则判的是上午。
3. risk_judgement_service.py:238/243:判断**与展示**都换算。展示不改的话,
   风控专员看到的时刻与直觉差 8 小时,无法与客户核对。
4. risk_daily_report_service.py:89:日界改用 local_day_bounds(按北京时间自然日,
   再折回 UTC naive)。原先按 UTC 日期切日,北京 08:00 前生成的日报统计窗口跨零点。

测试:
- 新增 tests/unit/core/test_timeutil.py(8 条),含"UTC 凌晨 0-6 点不是北京凌晨"
  这一缺陷复现,以及"日界必须返回 UTC naive"。
- 改写 test_risk_scan_service.py::test_night_small_trade_boundaries:它原本就拿
  UTC 小时构造数据(写 0 点/6 点),等于在测北京 08:00/14:00;语义一并修正为
  北京 00:00(含)与 06:00(不含)两个边界,三个边界场景保持不变。

ruff / mypy(135 文件) / 603 unit+contract 全绿。
This commit is contained in:
2026-09-11 12:50:52 +08:00
parent 39c7ab51f4
commit b3da1b65ed
6 changed files with 171 additions and 9 deletions
+68
View File
@@ -0,0 +1,68 @@
"""时区换算:库内存 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 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)