Files
group_fqcd_jr/app/service/risk_query_service.py
T
lzf_0626 a572c09a5c 风控列表游标绑定用户与查询条件(docs/25 遗留 P3)
docs/05 §3.8 要求游标绑定用户、查询条件、排序字段和方向,此前实现只把 offset
用 base64 包了一层:任何登录用户拿到别人的游标都能继续翻,换个筛选条件也能继续翻
(偏移量对不上就静默返回错页)。现在游标里携带 SHA-256 指纹:

- 指纹口径 = user_id + data_scope/customer_ids + 查询条件(排除 limit/cursor)
- 刻意排除 limit:它是分页参数、不是查询条件,算进去只会让翻页时改页大小失效
- /evidence/{source} 的 source 是路径参数,单独并入指纹,否则 customers 的
  游标能直接拿去翻 products
- 指纹不符一律 InvalidCursorError -> 400 INVALID_CURSOR(docs/05 §3.6)

新增 2 个单测:换用户/换筛选/换 data_scope 失效、改 limit 仍有效、
不同证据类型游标不互通。
2026-09-11 14:05:37 +08:00

229 lines
9.2 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 cursor_binding, 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")
binding = self._binding(context, query)
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, binding=binding),
),
)
return self._page(page, binding=binding)
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")
binding = self._binding(context, query, source)
page_request = PageRequest(
limit=query.limit,
offset=decode_offset_cursor(query.cursor, binding=binding),
)
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, binding=binding)
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, binding=binding)
def _repository(self, context: RequestContext) -> RiskRepository:
if self.repository is not None:
return self.repository
return RiskRepository(self.session, scope=scope_from_context(context))
@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)
@classmethod
def _page(cls, page: Any, *, binding: str) -> dict[str, Any]:
return {
"items": [cls._record(item) for item in page.items],
"next_cursor": (
encode_offset_cursor(page.next_offset, binding=binding)
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)