From b3da1b65edb28b851d55232784fbb431adb21513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Fri, 11 Sep 2026 12:50:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(risk):=20=E4=BF=AE=E6=AD=A3=E9=A3=8E?= =?UTF-8?q?=E6=8E=A7=E7=9A=84=E6=97=B6=E5=8C=BA=E7=BC=BA=E9=99=B7=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=87=8C=E6=99=A8=E8=A7=84=E5=88=99=E4=B8=8E=E6=97=A5?= =?UTF-8?q?=E6=8A=A5=E6=97=A5=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 全绿。 --- app/core/timeutil.py | 68 +++++++++++++++++ app/service/risk_daily_report_service.py | 9 ++- app/service/risk_judgement_service.py | 9 ++- app/service/risk_scan_service.py | 5 +- tests/unit/core/test_timeutil.py | 79 ++++++++++++++++++++ tests/unit/service/test_risk_scan_service.py | 10 ++- 6 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 app/core/timeutil.py create mode 100644 tests/unit/core/test_timeutil.py diff --git a/app/core/timeutil.py b/app/core/timeutil.py new file mode 100644 index 0000000..026b76b --- /dev/null +++ b/app/core/timeutil.py @@ -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) diff --git a/app/service/risk_daily_report_service.py b/app/service/risk_daily_report_service.py index 199e2be..5d48af9 100644 --- a/app/service/risk_daily_report_service.py +++ b/app/service/risk_daily_report_service.py @@ -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), diff --git a/app/service/risk_judgement_service.py b/app/service/risk_judgement_service.py index 5fd91be..23c0205 100644 --- a/app/service/risk_judgement_service.py +++ b/app/service/risk_judgement_service.py @@ -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)} 时。", "该规则本身属于低优先级运营特征初筛。", ], ["核验设备、交易地点和客户操作意图后人工决定是否放行。"], diff --git a/app/service/risk_scan_service.py b/app/service/risk_scan_service.py index 3955b06..221416d 100644 --- a/app/service/risk_scan_service.py +++ b/app/service/risk_scan_service.py @@ -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") diff --git a/tests/unit/core/test_timeutil.py b/tests/unit/core/test_timeutil.py new file mode 100644 index 0000000..a5af1f1 --- /dev/null +++ b/tests/unit/core/test_timeutil.py @@ -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) diff --git a/tests/unit/service/test_risk_scan_service.py b/tests/unit/service/test_risk_scan_service.py index a8739eb..6607288 100644 --- a/tests/unit/service/test_risk_scan_service.py +++ b/tests/unit/service/test_risk_scan_service.py @@ -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() == []