fix(risk): 收敛剩余的时区口径(REST 时间参数、年龄、日报日期字段)
接 b3da1b6。上一条只改了凌晨规则与日报日界,剩下几处一并收掉: 1. risk_query_service.py:77-78:REST 的 start_time/end_time 是**裸 datetime**, 原先原样透传去比库内 UTC 列,而 Agent 路径本来就带时区 (risk_natural_language.py:117)——同一条筛选条件在界面与对话里会查出不同结果。 timeutil 新增 from_local:裸值按**北京时间**解释(面向中国客户的业务系统, 填表人的预期就是本地时间),带时区的按其自身时区处理。它与 to_utc_naive 的区别 正在裸值上:取库里的值用后者,接客户端输入用这个。 2. risk_scan_service.py 与 risk_judgement_service.py 的 _age:一处用 UTC 日期、 一处用服务器 date.today(),生日边界上同一客户会差一岁、65 岁阈值可能翻面。 统一走 local_date(北京时间)。 3. risk_daily_report_service.py:122 的 report_date 与 :244 的 created_today: 原先取 UTC 日期,北京 08:00 之前会把"今天新增的预警"算成昨天。 ruff / mypy(135 文件) / 603 unit+contract 全绿。
This commit is contained in:
@@ -42,6 +42,21 @@ def to_utc_naive(value: datetime) -> datetime:
|
||||
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)。
|
||||
|
||||
|
||||
@@ -14,7 +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.core.timeutil import local_date, local_day_bounds
|
||||
from app.model.audit import InteractionAudit
|
||||
from app.repository.fund_query_repository import FundRecord
|
||||
from app.repository.risk_repository import RiskRepository
|
||||
@@ -119,7 +119,7 @@ class RiskDailyReportService:
|
||||
]
|
||||
return {
|
||||
"type": "风控日报",
|
||||
"report_date": generated_at.date().isoformat(),
|
||||
"report_date": local_date(generated_at).isoformat(),
|
||||
"generated_at": _local_datetime_text(generated_at),
|
||||
"daily_alert_count": len(daily),
|
||||
"level_distribution": _distribution(daily, "risk_level", LEVEL_ORDER),
|
||||
@@ -241,7 +241,11 @@ class RiskDailyReportService:
|
||||
"is_overdue": bool(due_at and due_at <= generated_at),
|
||||
"is_escalated": bool(item.get("is_escalated")),
|
||||
"close_reason": item.get("close_reason"),
|
||||
"created_today": bool(created_at and created_at.date() == generated_at.date()),
|
||||
# 两边都按**北京时间**取日期:原先比的是 UTC 日期,北京 08:00 之前
|
||||
# 会把"今天新增的预警"算成昨天(docs/25 P1 #6)。
|
||||
"created_today": bool(
|
||||
created_at and local_date(created_at) == local_date(generated_at)
|
||||
),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from app.core.timeutil import local_hour
|
||||
from app.core.timeutil import local_date, local_hour
|
||||
|
||||
VERDICT_RELEASE = "可考虑放行"
|
||||
VERDICT_SUSPECTED_FALSE_POSITIVE = "疑似误报"
|
||||
@@ -383,7 +383,10 @@ def _age(value: Any) -> int | None:
|
||||
birth_date = _date(value)
|
||||
if birth_date is None:
|
||||
return None
|
||||
today = date.today()
|
||||
# 用**北京时间**的今天。原先这里用服务器 date.today()(东八区的机器上是北京日期,
|
||||
# 但不保证),而扫描侧用 UTC 日期——两处口径不一致会让同一客户在生日边界上差一岁
|
||||
# (docs/25 P1 #6)。统一走 timeutil。
|
||||
today = local_date(datetime.now(UTC))
|
||||
return today.year - birth_date.year - (
|
||||
(today.month, today.day) < (birth_date.month, birth_date.day)
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.api.schemas.risk import RiskAlertPageQuery, RiskEvidencePageQuery
|
||||
from app.core.contracts import RequestContext
|
||||
from app.core.errors import GenericResourceNotFoundError
|
||||
from app.core.risk_cursor import decode_offset_cursor, encode_offset_cursor
|
||||
from app.core.timeutil import from_local
|
||||
from app.repository.fund_query_repository import CustomerScope, PageRequest
|
||||
from app.repository.risk_repository import RiskRepository
|
||||
from app.service.authorization_service import AuthorizationService
|
||||
@@ -74,8 +75,11 @@ class RiskQueryService:
|
||||
product_name=query.product_name,
|
||||
risk_level=query.risk_level,
|
||||
rule_code=query.rule_code,
|
||||
start_time=query.start_time,
|
||||
end_time=query.end_time,
|
||||
# REST 的时间参数是裸 datetime(不带时区),按**北京时间**解释后再换算成
|
||||
# 库内 UTC。Agent 路径本来就带时区(risk_natural_language.py:117),
|
||||
# 两条路径口径必须一致,否则同一个筛选条件在界面与对话里查出不同结果。
|
||||
start_time=from_local(query.start_time) if query.start_time else None,
|
||||
end_time=from_local(query.end_time) if query.end_time else None,
|
||||
page=PageRequest(limit=query.limit, offset=decode_offset_cursor(query.cursor)),
|
||||
)
|
||||
return self._page(page)
|
||||
|
||||
@@ -18,7 +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.core.timeutil import local_date, local_hour
|
||||
from app.model.audit import InteractionAudit
|
||||
from app.model.fund import (
|
||||
FundCapitalFlow,
|
||||
@@ -462,7 +462,9 @@ def _new_alert_id() -> int:
|
||||
def _age(birth_date: date | None) -> int:
|
||||
if birth_date is None:
|
||||
return 0
|
||||
today = datetime.now(UTC).date()
|
||||
# 用**北京时间**的今天:原先用 UTC 日期,而研判侧用服务器 date.today(),
|
||||
# 两处口径不一致会让同一客户在生日边界上差一岁(docs/25 P1 #6)。
|
||||
today = local_date(datetime.now(UTC))
|
||||
return today.year - birth_date.year - (
|
||||
(today.month, today.day) < (birth_date.month, birth_date.day)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user