Files
group_fqcd_jr/app/service/risk_action_service.py
T

234 lines
8.2 KiB
Python

"""预警人工处置服务,集中管理状态流转、行为分和审计。"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import false, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.contracts import RequestContext
from app.core.errors import ConflictAgentError, GenericResourceNotFoundError
from app.model.audit import InteractionAudit
from app.model.fund import FundCustomerProfile, FundRiskAlert
from app.service.authorization_service import AuthorizationService
OPEN_STATUSES = ("待处理", "调查中")
BEHAVIOR_SCORE_INITIAL = 20
BEHAVIOR_SCORE_DEDUCTIONS = {"低": 3, "中": 5, "高": 20}
class RiskActionError(ConflictAgentError):
"""预警状态不允许执行当前动作。"""
class RiskActionService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def acknowledge(self, alert_no: str, context: RequestContext) -> dict[str, Any]:
await AuthorizationService.require(context, "risk:alert:write")
alert = await self._load_for_update(alert_no, context)
if alert.status != "待处理":
raise RiskActionError("只有待处理预警可以确认接收")
if alert.ack_at is not None:
raise RiskActionError("预警已经确认接收,不能重复提交")
now = _now()
alert.ack_status = "已确认"
alert.ack_at = now
alert.handler_id = int(context.user_id)
alert.updated_at = now
await self._finish(alert, context, "risk_alert_acknowledged", {"alert_no": alert.alert_no})
return self._view(alert)
async def investigate(self, alert_no: str, context: RequestContext) -> dict[str, Any]:
await AuthorizationService.require(context, "risk:alert:write")
alert = await self._load_for_update(alert_no, context)
self._require_acknowledged(alert)
if alert.status != "待处理":
raise RiskActionError("只有待处理预警可以进入调查中")
now = _now()
alert.status = "调查中"
alert.updated_at = now
await self._finish(alert, context, "risk_alert_investigating", {"alert_no": alert.alert_no})
return self._view(alert)
async def exclude(
self,
alert_no: str,
reason: str,
context: RequestContext,
) -> dict[str, Any]:
await AuthorizationService.require(context, "risk:alert:write")
alert = await self._load_for_update(alert_no, context)
self._require_acknowledged(alert)
self._require_open(alert, "关闭误报")
now = _now()
alert.status = "已排除"
alert.closed_at = now
alert.close_reason = reason
alert.handle_result = reason
alert.updated_at = now
await self._finish(
alert,
context,
"risk_alert_excluded",
{"alert_no": alert.alert_no, "reason": reason},
)
return self._view(alert)
async def resolve(
self,
alert_no: str,
resolution: str,
context: RequestContext,
) -> dict[str, Any]:
await AuthorizationService.require(context, "risk:alert:write")
alert = await self._load_for_update(alert_no, context)
self._require_acknowledged(alert)
if alert.status != "调查中":
raise RiskActionError("只有调查中的预警可以完成处置")
profile = await self.session.scalar(
select(FundCustomerProfile)
.where(FundCustomerProfile.customer_id == alert.customer_id)
.with_for_update()
)
if profile is None:
raise RiskActionError("客户画像不存在,无法更新行为分")
deduction = BEHAVIOR_SCORE_DEDUCTIONS.get(alert.alert_level)
if deduction is None:
raise RiskActionError("预警风险等级无效,无法更新行为分")
score_before = int(profile.behavior_score)
score_after = max(0, min(BEHAVIOR_SCORE_INITIAL, max(0, score_before)) - deduction)
now = _now()
alert.status = "已结案"
alert.closed_at = now
alert.handle_result = resolution
alert.updated_at = now
profile.behavior_score = score_after
profile.updated_at = now
await self._finish(
alert,
context,
"risk_alert_resolved",
{
"alert_no": alert.alert_no,
"resolution": resolution,
"behavior_score_before": score_before,
"behavior_score_deduction": deduction,
"behavior_score_after": score_after,
},
)
result = self._view(alert)
result.update({
"behavior_score_before": score_before,
"behavior_score_deduction": deduction,
"behavior_score_after": score_after,
})
return result
async def escalate(
self,
alert_no: str,
reason: str,
context: RequestContext,
) -> dict[str, Any]:
await AuthorizationService.require(context, "risk:alert:write")
alert = await self._load_for_update(alert_no, context)
self._require_acknowledged(alert)
self._require_open(alert, "升级处理")
if alert.is_escalated:
raise RiskActionError("预警已经升级,不能重复提交")
now = _now()
alert.is_escalated = 1
alert.escalated_at = now
alert.escalation_reason = reason
alert.manual_remark = reason
alert.updated_at = now
await self._finish(
alert,
context,
"risk_alert_escalated",
{"alert_no": alert.alert_no, "reason": reason},
)
result = self._view(alert)
result.update({
"is_escalated": True,
"escalated_at": alert.escalated_at.isoformat(),
"escalation_reason": alert.escalation_reason,
})
return result
async def _load_for_update(
self,
alert_no: str,
context: RequestContext,
) -> FundRiskAlert:
statement = (
select(FundRiskAlert)
.where(FundRiskAlert.alert_no == alert_no)
.with_for_update()
)
scope = self._scope_condition(context)
if scope is not None:
statement = statement.where(scope)
alert = await self.session.scalar(statement)
if alert is None:
raise GenericResourceNotFoundError("预警不存在")
return alert
async def _finish(
self,
alert: FundRiskAlert,
context: RequestContext,
action: str,
detail: dict[str, Any],
) -> None:
self.session.add(InteractionAudit(
actor_type="user",
actor_id=int(context.user_id),
target_customer_id=alert.customer_id,
portal="api",
action_type=action,
detail=detail,
created_at=_now(),
))
await self.session.commit()
await self.session.refresh(alert)
@staticmethod
def _require_acknowledged(alert: FundRiskAlert) -> None:
if alert.ack_at is None:
raise RiskActionError("请先确认接收预警")
@staticmethod
def _require_open(alert: FundRiskAlert, action: str) -> None:
if alert.status not in OPEN_STATUSES:
raise RiskActionError(f"当前状态为{alert.status},不能执行{action}")
@staticmethod
def _scope_condition(context: RequestContext) -> Any:
if context.data_scope == "all":
return None
if not context.customer_ids:
return false()
return FundRiskAlert.customer_id.in_(
tuple(int(customer_id) for customer_id in context.customer_ids)
)
@staticmethod
def _view(alert: FundRiskAlert) -> dict[str, Any]:
return {
"alert_no": alert.alert_no,
"status": alert.status,
"ack_status": alert.ack_status,
"alert_level": alert.alert_level,
"handle_result": alert.handle_result,
"closed_at": alert.closed_at.isoformat() if alert.closed_at else None,
}
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)