diff --git a/app/core/timeutil.py b/app/core/timeutil.py index 026b76b..426a9e7 100644 --- a/app/core/timeutil.py +++ b/app/core/timeutil.py @@ -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)。 diff --git a/app/service/risk_daily_report_service.py b/app/service/risk_daily_report_service.py index 5d48af9..6944c14 100644 --- a/app/service/risk_daily_report_service.py +++ b/app/service/risk_daily_report_service.py @@ -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 diff --git a/app/service/risk_judgement_service.py b/app/service/risk_judgement_service.py index 23c0205..e3b3b33 100644 --- a/app/service/risk_judgement_service.py +++ b/app/service/risk_judgement_service.py @@ -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) ) diff --git a/app/service/risk_query_service.py b/app/service/risk_query_service.py index 3f1b8c6..87f319f 100644 --- a/app/service/risk_query_service.py +++ b/app/service/risk_query_service.py @@ -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) diff --git a/app/service/risk_scan_service.py b/app/service/risk_scan_service.py index 221416d..11ec32c 100644 --- a/app/service/risk_scan_service.py +++ b/app/service/risk_scan_service.py @@ -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) )