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:
@@ -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)
|
||||
@@ -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() == []
|
||||
|
||||
Reference in New Issue
Block a user