Files
group_fqcd_jr/app/service/risk_query_service.py
T
lzf_0626 8ac0b794ff 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 全绿。
2026-09-11 12:55:00 +08:00

203 lines
7.6 KiB
Python

"""风控只读查询服务,负责授权、范围、分页和对外投影。"""
from __future__ import annotations
from collections.abc import Mapping
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any, cast
from sqlalchemy.ext.asyncio import AsyncSession
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
ID_FIELDS = {
"id",
"customer_id",
"alert_id",
"transaction_id",
"account_id",
"product_id",
"related_transaction_id",
"related_order_id",
"related_work_order_id",
"primary_risk_work_order_id",
"handler_id",
"submitter_id",
"advisor_id",
}
class RiskQueryService:
def __init__(
self,
session: AsyncSession,
*,
repository: RiskRepository | None = None,
) -> None:
self.session = session
self.repository = repository
async def overview(self, context: RequestContext) -> dict[str, Any]:
await AuthorizationService.require(context, "risk:alert:read")
repository = self._repository(context)
result = await repository.overview()
levels = result["levels"]
return {
"total": result["total"],
"levels": {
"高风险": levels.get("高", 0),
"中风险": levels.get("中", 0),
"低风险": levels.get("低", 0),
},
"pending": result["pending"],
"overdue": result["overdue"],
"high_priority": [self._record(item) for item in result["high_priority"]],
}
async def list_alerts(
self,
context: RequestContext,
query: RiskAlertPageQuery,
) -> dict[str, Any]:
await AuthorizationService.require(context, "risk:alert:read")
page = await self._repository(context).list_alerts(
keyword=query.keyword,
customer_no=query.customer_no,
product_code=query.product_code,
product_name=query.product_name,
risk_level=query.risk_level,
rule_code=query.rule_code,
# 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)
async def get_alert_detail(
self,
context: RequestContext,
alert_no: str,
) -> dict[str, Any]:
await AuthorizationService.require(context, "risk:alert:read")
record = await self._repository(context).get_alert_detail(alert_no)
if record is None:
raise GenericResourceNotFoundError("预警不存在")
return self._record(record)
async def list_evidence(
self,
context: RequestContext,
source: str,
query: RiskEvidencePageQuery,
) -> dict[str, Any]:
await AuthorizationService.require(context, "risk:alert:read")
page_request = PageRequest(
limit=query.limit,
offset=decode_offset_cursor(query.cursor),
)
repository = self._repository(context)
if source == "customers":
page = await repository.list_customers(
keyword=query.keyword,
behavior_level=query.behavior_level,
page=page_request,
)
elif source == "products":
page = await repository.list_products(keyword=query.keyword, page=page_request)
elif source == "transactions":
page = await repository.list_transactions(
keyword=query.keyword,
start_time=query.start_time,
end_time=query.end_time,
page=page_request,
)
elif source == "capital_flows":
page = await repository.list_capital_flows(
keyword=query.keyword,
start_time=query.start_time,
end_time=query.end_time,
page=page_request,
)
elif source == "holdings":
page = await repository.list_holdings(keyword=query.keyword, page=page_request)
elif source == "login_records":
page = await repository.list_login_records(
keyword=query.keyword,
start_time=query.start_time,
end_time=query.end_time,
page=page_request,
)
elif source == "alerts":
alert_page = await repository.list_alerts(keyword=query.keyword, page=page_request)
return self._page(alert_page)
elif source == "notifications":
page = await repository.list_notifications(
keyword=query.keyword,
send_status=query.send_status,
start_time=query.start_time,
end_time=query.end_time,
page=page_request,
)
else:
raise GenericResourceNotFoundError("证据类型不存在")
return self._page(page)
def _repository(self, context: RequestContext) -> RiskRepository:
if self.repository is not None:
return self.repository
return RiskRepository(self.session, scope=scope_from_context(context))
@classmethod
def _page(cls, page: Any) -> dict[str, Any]:
return {
"items": [cls._record(item) for item in page.items],
"next_cursor": (
encode_offset_cursor(page.next_offset)
if page.next_offset is not None
else None
),
"has_more": page.has_more,
}
@classmethod
def _record(cls, record: Any) -> dict[str, Any]:
values = record.to_dict() if hasattr(record, "to_dict") else dict(record)
return cast(dict[str, Any], cls._plain(values))
@classmethod
def _plain(cls, value: Any, *, field: str | None = None) -> Any:
if isinstance(value, Mapping):
return {key: cls._plain(item, field=str(key)) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [cls._plain(item, field=field) for item in value]
if isinstance(value, Decimal):
return str(value)
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
if isinstance(value, date):
return value.isoformat()
if field in ID_FIELDS and isinstance(value, int) and not isinstance(value, bool):
return str(value)
return value
def scope_from_context(context: RequestContext) -> CustomerScope:
if context.data_scope == "all":
return CustomerScope.unrestricted()
if not context.customer_ids:
return CustomerScope.denied()
return CustomerScope.for_customers(int(customer_id) for customer_id in context.customer_ids)