Files
group_fqcd_jr/app/service/risk_notification_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

164 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""风控通知记录 Service。
本阶段只创建通知记录和提供分页查询,不执行 SMTP 外发。
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.schemas.risk import RiskNotificationPageQuery
from app.core.contracts import RequestContext
from app.core.risk_cursor import decode_offset_cursor
from app.model.fund import FundRiskAlert, FundRiskNotification
from app.repository.fund_query_repository import PageRequest
from app.repository.risk_repository import RiskRepository
from app.service.authorization_service import AuthorizationService
from app.service.risk_query_service import RiskQueryService, scope_from_context
class RiskNotificationService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list_notifications(
self,
context: RequestContext,
query: RiskNotificationPageQuery,
) -> dict[str, Any]:
await AuthorizationService.require(context, "risk:alert:read")
binding = RiskQueryService._binding(context, query, "/notifications")
page = await RiskRepository(
self.session,
scope=scope_from_context(context),
).list_notifications(
keyword=query.keyword,
send_status=query.send_status,
start_time=query.start_time,
end_time=query.end_time,
page=PageRequest(
limit=query.limit,
offset=decode_offset_cursor(query.cursor, binding=binding),
),
)
return RiskQueryService._page(page, binding=binding)
def create_in_app(
self,
alert: FundRiskAlert,
*,
receiver_user_id: int | None,
title: str,
content: str,
) -> FundRiskNotification:
notification = self._build(
alert=alert,
channel="站内提醒",
receiver_user_id=receiver_user_id,
receiver_email=None,
title=title,
content=content,
send_status="已发送",
sent_at=_now(),
fail_reason=None,
)
self.session.add(notification)
return notification
def create_mail_record(
self,
alert: FundRiskAlert,
*,
receiver_email: str,
title: str,
content: str,
mail_enabled: bool = False,
) -> FundRiskNotification:
notification = self._build(
alert=alert,
channel="邮件",
receiver_user_id=None,
receiver_email=receiver_email,
title=title,
content=content,
send_status="待发送" if mail_enabled else "未启用",
sent_at=None,
fail_reason=None if mail_enabled else "邮件发送功能未启用",
)
self.session.add(notification)
return notification
def create_high_risk_records(
self,
alerts: list[FundRiskAlert],
*,
receiver_user_id: int | None = None,
receiver_email: str | None = None,
mail_enabled: bool = False,
) -> list[FundRiskNotification]:
notifications: list[FundRiskNotification] = []
for alert in alerts:
if alert.alert_level != "高":
continue
title = f"高风险预警:{alert.alert_type}"
notifications.append(self.create_in_app(
alert,
receiver_user_id=receiver_user_id,
title=title,
content=alert.evidence_summary,
))
if receiver_email:
notifications.append(self.create_mail_record(
alert,
receiver_email=receiver_email,
title=title,
content=alert.evidence_summary,
mail_enabled=mail_enabled,
))
return notifications
@staticmethod
def _build(
*,
alert: FundRiskAlert,
channel: str,
receiver_user_id: int | None,
receiver_email: str | None,
title: str,
content: str,
send_status: str,
sent_at: datetime | None,
fail_reason: str | None,
) -> FundRiskNotification:
referenced_content = (
content
if f"预警编号:{alert.alert_no}" in content
else f"预警编号:{alert.alert_no};{content}"
)
return FundRiskNotification(
id=_new_notification_id(),
notification_no=f"N{uuid4().hex[:12].upper()}",
alert_id=alert.id,
channel=channel,
receiver_user_id=receiver_user_id,
receiver_email=receiver_email,
title=title,
content=referenced_content,
send_status=send_status,
sent_at=sent_at,
fail_reason=fail_reason,
created_at=_now(),
)
def _new_notification_id() -> int:
return uuid4().int & ((1 << 63) - 1) or 1
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)