234 lines
8.2 KiB
Python
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)
|