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)
+6 -3
View File
@@ -6,7 +6,7 @@ import json
import logging
from collections import Counter
from collections.abc import AsyncIterator
from datetime import UTC, datetime, time, timedelta
from datetime import UTC, datetime
from typing import Any
from zoneinfo import ZoneInfo
@@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.contracts import RequestContext
from app.core.timeutil import local_day_bounds
from app.model.audit import InteractionAudit
from app.repository.fund_query_repository import FundRecord
from app.repository.risk_repository import RiskRepository
@@ -86,8 +87,10 @@ class RiskDailyReportService:
context: RequestContext,
generated_at: datetime,
) -> dict[str, Any]:
day_start = datetime.combine(generated_at.date(), time.min)
next_day = day_start + timedelta(days=1)
# 日界按**北京时间**切,再换算回库内 UTC 格式去查询。原先直接用 UTC 日期切日,
# 北京 08:00 前生成的日报,统计窗口会变成"前一日 08:00–当日 08:00"、跨了零点
# (docs/25 P1 #6)。查询列是 UTC,所以 local_day_bounds 返回的也是 UTC naive。
day_start, next_day = local_day_bounds(generated_at)
repository = self.repository or RiskRepository(
self.session,
scope=scope_from_context(context),
+7 -2
View File
@@ -11,6 +11,8 @@ from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from typing import Any
from app.core.timeutil import local_hour
VERDICT_RELEASE = "可考虑放行"
VERDICT_SUSPECTED_FALSE_POSITIVE = "疑似误报"
VERDICT_CONTINUE_REVIEW = "继续复核"
@@ -235,12 +237,15 @@ def _assess_rw015(detail: dict[str, Any]) -> dict[str, Any]:
["缺少交易金额或成交时间。"],
["补查交易明细和成交时间。"],
)
if amount <= Decimal("10000") and 0 <= confirmed_at.hour < 6:
# 必须按**北京时间**判断"凌晨":库里存 UTC,直接取 .hour 会让 [0,6) UTC 变成
# 北京 08:00–14:00(docs/25 P1 #6)。展示的小时数同样要换算,否则风控专员看到的
# 是 UTC 时刻、与他的直觉差 8 小时,无法核对。
if amount <= Decimal("10000") and 0 <= local_hour(confirmed_at) < 6:
return _assessment(
VERDICT_RELEASE,
"中",
[
f"交易金额 {amount:.2f} 元较小,并发生在 {confirmed_at.hour} 时。",
f"交易金额 {amount:.2f} 元较小,并发生在 {local_hour(confirmed_at)} 时。",
"该规则本身属于低优先级运营特征初筛。",
],
["核验设备、交易地点和客户操作意图后人工决定是否放行。"],
+4 -1
View File
@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.contracts import RequestContext
from app.core.errors import AgentError, ConflictAgentError
from app.core.timeutil import local_hour
from app.model.audit import InteractionAudit
from app.model.fund import (
FundCapitalFlow,
@@ -245,7 +246,9 @@ class RiskRuleEngine:
for transaction in await self._scalars(select(FundTransaction)):
if (
transaction.confirmed_at is not None
and 0 <= transaction.confirmed_at.hour < 6
# 必须按**北京时间**判断"凌晨":库里存 UTC,直接取 .hour 会让
# [0,6) UTC 变成北京 08:00–14:00,整条规则判的是上午(docs/25 P1 #6)。
and 0 <= local_hour(transaction.confirmed_at) < 6
and transaction.amount is not None
and transaction.amount <= 10000
and not await self._exists(transaction.id, "RW-015")
+79
View File
@@ -0,0 +1,79 @@
"""时区换算工具的单元测试。
这个模块存在的理由是一次真实缺陷:风控的「凌晨时段小额操作」规则直接取库内 UTC 时间的
`.hour`,于是 `[0,6)` UTC 被判成"凌晨",而它实际是北京时间 **08:00–14:00** —— 整条规则
判的是上午(`docs/25` P1 #6)。日报也按 UTC 日切却按北京时间展示,两处对"同一天"的理解
差 8 小时。
所以这里把边界钉死:**只要有人再把 UTC 小时当北京时间用,就有用例会红。**
"""
from datetime import UTC, date, datetime
from zoneinfo import ZoneInfo
from app.core.timeutil import (
local_date,
local_day_bounds,
local_hour,
to_local,
to_utc_naive,
)
def test_utc_sixteen_hundred_is_beijing_midnight() -> None:
"""库里 16:00 UTC 是北京次日 00:00 —— 这正是"凌晨"窗口的真正起点。"""
assert local_hour(datetime(2026, 9, 9, 16, 0)) == 0
def test_night_window_boundaries() -> None:
"""北京 00:00–06:00 的凌晨窗口,对应 UTC 前一日 16:00–22:00。"""
assert local_hour(datetime(2026, 9, 9, 15, 59)) == 23 # 北京 23:59,还没到凌晨
assert local_hour(datetime(2026, 9, 9, 16, 0)) == 0 # 北京 00:00,起点(含)
assert local_hour(datetime(2026, 9, 9, 21, 59)) == 5 # 北京 05:59,仍在窗口内
assert local_hour(datetime(2026, 9, 9, 22, 0)) == 6 # 北京 06:00,终点(不含)
def test_utc_morning_is_not_night_in_beijing() -> None:
"""这条是原缺陷的复现:UTC 凌晨 0-6 点其实是北京的上午,绝不能被当成"凌晨"。"""
for utc_hour in (0, 2, 4, 5):
assert local_hour(datetime(2026, 9, 9, utc_hour, 0)) not in range(0, 6)
def test_local_date_rolls_over_at_beijing_midnight() -> None:
"""日期归属按北京:UTC 15:59 还是北京的当天,16:00 已经是次日。"""
assert local_date(datetime(2026, 9, 9, 15, 59)) == date(2026, 9, 9)
assert local_date(datetime(2026, 9, 9, 16, 0)) == date(2026, 9, 10)
def test_local_day_bounds_is_returned_in_utc_naive_for_querying() -> None:
"""日界必须换算回库内格式(UTC naive):它要拿去查 UTC 列,
返回本地时间会让区间与库内值整体错开 8 小时。"""
start, end = local_day_bounds(datetime(2026, 9, 9, 20, 0)) # = 北京 09-10 04:00
assert start == datetime(2026, 9, 9, 16, 0) # 北京 09-10 00:00
assert end == datetime(2026, 9, 10, 16, 0) # 北京 09-11 00:00
assert start.tzinfo is None and end.tzinfo is None
assert (end - start).days == 1
def test_local_day_bounds_before_beijing_eight_am_keeps_the_local_day() -> None:
"""北京 08:00 前生成日报时,日界不能滑到前一天 —— 这是原 bug 的复现场景。"""
start, _end = local_day_bounds(datetime(2026, 9, 9, 23, 0)) # = 北京 09-10 07:00
assert local_date(start) == date(2026, 9, 10)
def test_aware_input_is_interpreted_in_its_own_zone() -> None:
"""带时区的入参按它自己声明的时区解释 —— 那说明它来自库外(例如请求参数)。"""
shanghai_noon = datetime(2026, 9, 10, 12, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
assert to_utc_naive(shanghai_noon) == datetime(2026, 9, 10, 4, 0)
assert local_hour(shanghai_noon) == 12
def test_to_local_keeps_aware_value_consistent() -> None:
"""naive 与"同值的 aware(UTC)"必须换算成同一个本地时刻。"""
naive = datetime(2026, 9, 9, 16, 0)
aware = datetime(2026, 9, 9, 16, 0, tzinfo=UTC)
assert to_local(naive) == to_local(aware)
+7 -3
View File
@@ -530,20 +530,24 @@ async def test_elderly_redemption_rejects_common_device() -> None:
@pytest.mark.asyncio
async def test_night_small_trade_boundaries() -> None:
# confirmed_at 在库里是 **UTC**(见 app/infrastructure/db.py:15-21),而"凌晨"是
# **北京时间**概念:北京 00:00–06:00 对应 UTC 的前一日 16:00–22:00。
# 原先这里直接拿 UTC 小时当北京时间(写 0 点 / 6 点),等于在测"北京 08:00 / 14:00",
# 规则修正后这些用例的语义也一并修正(docs/25 P1 #6)。
night = transaction()
night.confirmed_at = datetime(2026, 9, 10, 0, 0)
night.confirmed_at = datetime(2026, 9, 9, 16, 0) # 北京 09-10 00:00,凌晨起点(含)
night.amount = Decimal("10000.00")
session = FakeSession(rows=[night], scalar_values=[None])
alerts = await RiskRuleEngine(session)._low_risk_night_trade()
assert len(alerts) == 1
regular = transaction()
regular.confirmed_at = datetime(2026, 9, 10, 6, 0)
regular.confirmed_at = datetime(2026, 9, 9, 22, 0) # 北京 09-10 06:00,凌晨终点(不含)
session = FakeSession(rows=[regular], scalar_values=[None])
assert await RiskRuleEngine(session)._low_risk_night_trade() == []
too_large = transaction()
too_large.confirmed_at = datetime(2026, 9, 10, 2, 0)
too_large.confirmed_at = datetime(2026, 9, 9, 18, 0) # 北京 09-10 02:00,在凌晨但金额超限
too_large.amount = Decimal("10000.01")
session = FakeSession(rows=[too_large], scalar_values=[None])
assert await RiskRuleEngine(session)._low_risk_night_trade() == []