2026-09-10 21:03:44 +08:00
|
|
|
"""风控只读查询服务,负责授权、范围、分页和对外投影。"""
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-11 14:05:37 +08:00
|
|
|
from app.core.risk_cursor import cursor_binding, decode_offset_cursor, encode_offset_cursor
|
2026-09-11 12:55:00 +08:00
|
|
|
from app.core.timeutil import from_local
|
2026-09-10 21:03:44 +08:00
|
|
|
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")
|
2026-09-11 14:05:37 +08:00
|
|
|
binding = self._binding(context, query)
|
2026-09-10 21:03:44 +08:00
|
|
|
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,
|
2026-09-11 12:55:00 +08:00
|
|
|
# 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,
|
2026-09-11 14:05:37 +08:00
|
|
|
page=PageRequest(
|
|
|
|
|
limit=query.limit,
|
|
|
|
|
offset=decode_offset_cursor(query.cursor, binding=binding),
|
|
|
|
|
),
|
2026-09-10 21:03:44 +08:00
|
|
|
)
|
2026-09-11 14:05:37 +08:00
|
|
|
return self._page(page, binding=binding)
|
2026-09-10 21:03:44 +08:00
|
|
|
|
|
|
|
|
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")
|
2026-09-11 14:05:37 +08:00
|
|
|
binding = self._binding(context, query, source)
|
2026-09-10 21:03:44 +08:00
|
|
|
page_request = PageRequest(
|
|
|
|
|
limit=query.limit,
|
2026-09-11 14:05:37 +08:00
|
|
|
offset=decode_offset_cursor(query.cursor, binding=binding),
|
2026-09-10 21:03:44 +08:00
|
|
|
)
|
|
|
|
|
repository = self._repository(context)
|
2026-09-11 15:25:45 +08:00
|
|
|
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
|
2026-09-10 21:03:44 +08:00
|
|
|
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,
|
2026-09-11 15:25:45 +08:00
|
|
|
start_time=start_time,
|
|
|
|
|
end_time=end_time,
|
2026-09-10 21:03:44 +08:00
|
|
|
page=page_request,
|
|
|
|
|
)
|
|
|
|
|
elif source == "capital_flows":
|
|
|
|
|
page = await repository.list_capital_flows(
|
|
|
|
|
keyword=query.keyword,
|
2026-09-11 15:25:45 +08:00
|
|
|
start_time=start_time,
|
|
|
|
|
end_time=end_time,
|
2026-09-10 21:03:44 +08:00
|
|
|
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,
|
2026-09-11 15:25:45 +08:00
|
|
|
start_time=start_time,
|
|
|
|
|
end_time=end_time,
|
2026-09-10 21:03:44 +08:00
|
|
|
page=page_request,
|
|
|
|
|
)
|
|
|
|
|
elif source == "alerts":
|
2026-09-11 16:40:15 +08:00
|
|
|
alert_page = await repository.list_alerts(
|
|
|
|
|
keyword=query.keyword,
|
|
|
|
|
open_only=False,
|
|
|
|
|
page=page_request,
|
|
|
|
|
)
|
2026-09-11 14:05:37 +08:00
|
|
|
return self._page(alert_page, binding=binding)
|
2026-09-10 21:03:44 +08:00
|
|
|
elif source == "notifications":
|
|
|
|
|
page = await repository.list_notifications(
|
|
|
|
|
keyword=query.keyword,
|
|
|
|
|
send_status=query.send_status,
|
2026-09-11 15:25:45 +08:00
|
|
|
start_time=start_time,
|
|
|
|
|
end_time=end_time,
|
2026-09-10 21:03:44 +08:00
|
|
|
page=page_request,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
raise GenericResourceNotFoundError("证据类型不存在")
|
2026-09-11 14:05:37 +08:00
|
|
|
return self._page(page, binding=binding)
|
2026-09-10 21:03:44 +08:00
|
|
|
|
|
|
|
|
def _repository(self, context: RequestContext) -> RiskRepository:
|
|
|
|
|
if self.repository is not None:
|
|
|
|
|
return self.repository
|
|
|
|
|
return RiskRepository(self.session, scope=scope_from_context(context))
|
|
|
|
|
|
2026-09-11 14:05:37 +08:00
|
|
|
@staticmethod
|
|
|
|
|
def _binding(context: RequestContext, query: Any, extra: Any = None) -> str:
|
|
|
|
|
"""列表查询的游标绑定指纹:用户 + 查询条件(**不含分页参数**)。
|
|
|
|
|
|
|
|
|
|
`docs/05` §3.8 要求游标绑定「用户、查询条件、排序字段和方向」。刻意排除
|
|
|
|
|
`limit` 与 `cursor`:它们是分页参数、不是"查什么";若算进指纹,客户翻页时改一下
|
|
|
|
|
limit 就会让游标失效 —— 那不是安全要求,只是难用。
|
|
|
|
|
|
|
|
|
|
`model_dump(mode="json")` 保证取值可稳定序列化(datetime / Decimal 都会变成确定的
|
|
|
|
|
字符串),否则同一个查询两次算出的指纹可能不同。
|
|
|
|
|
|
|
|
|
|
`extra` 用于路径参数那种"不在 query 里但决定查哪张表"的条件(`/evidence/{source}`),
|
|
|
|
|
不带上它的话 customers 的游标可以直接拿去翻 products。
|
|
|
|
|
"""
|
|
|
|
|
filters = query.model_dump(exclude={"limit", "cursor"}, mode="json")
|
|
|
|
|
if extra is not None:
|
|
|
|
|
filters["_path"] = extra
|
|
|
|
|
# data_scope 同样决定"能查到什么":权限被收紧后,旧游标不该还能继续往下翻。
|
|
|
|
|
filters["_scope"] = [context.data_scope, list(context.customer_ids or ())]
|
|
|
|
|
return cursor_binding(user_id=context.user_id, filters=filters)
|
|
|
|
|
|
2026-09-10 21:03:44 +08:00
|
|
|
@classmethod
|
2026-09-11 14:05:37 +08:00
|
|
|
def _page(cls, page: Any, *, binding: str) -> dict[str, Any]:
|
2026-09-10 21:03:44 +08:00
|
|
|
return {
|
|
|
|
|
"items": [cls._record(item) for item in page.items],
|
|
|
|
|
"next_cursor": (
|
2026-09-11 14:05:37 +08:00
|
|
|
encode_offset_cursor(page.next_offset, binding=binding)
|
2026-09-10 21:03:44 +08:00
|
|
|
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)
|