332 lines
10 KiB
Python
332 lines
10 KiB
Python
"""风控监控:统计、风险事件列表/详情及忽略、封禁、解封。"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Annotated, Literal
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
|
|
from app.admin.audit import write_audit
|
|
from app.admin.deps import (
|
|
AdminDb,
|
|
get_client_ip,
|
|
require_page,
|
|
require_role,
|
|
)
|
|
from app.admin.repositories import risk_monitor as repo
|
|
from app.admin.schemas.risk_monitor import (
|
|
RestrictionRevokeResponse,
|
|
RiskActionRequest,
|
|
RiskActionResponse,
|
|
RiskDetailPage,
|
|
RiskIncidentPage,
|
|
RiskMonitorSummary,
|
|
RiskResetResponse,
|
|
RiskRuleConfig,
|
|
)
|
|
from app.core.config_schema import (
|
|
RISK_COMPARE_DAILY_THRESHOLD_KEY,
|
|
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
|
|
RISK_SMS_HOURLY_THRESHOLD_KEY,
|
|
)
|
|
from app.models.risk import RiskIncident, SubjectRestriction
|
|
from app.repositories import app_config
|
|
from app.repositories import risk as risk_repo
|
|
|
|
router = APIRouter(
|
|
prefix="/admin/api/risk-monitor",
|
|
tags=["admin-risk-monitor"],
|
|
dependencies=[Depends(require_page("risk-monitor"))],
|
|
)
|
|
|
|
RiskKind = Literal["sms", "oneclick", "compare"]
|
|
|
|
|
|
def _rule_config(db) -> RiskRuleConfig:
|
|
return RiskRuleConfig(
|
|
sms_hourly_threshold=risk_repo.get_rule_threshold(
|
|
db, risk_repo.RULE_SMS_HOURLY
|
|
),
|
|
oneclick_daily_threshold=risk_repo.get_rule_threshold(
|
|
db, risk_repo.RULE_ONECLICK_DAILY
|
|
),
|
|
compare_daily_threshold=risk_repo.get_rule_threshold(
|
|
db, risk_repo.RULE_COMPARE_DAILY
|
|
),
|
|
)
|
|
|
|
|
|
def _incident_or_404(db, incident_id: int, kind: RiskKind | None = None) -> RiskIncident:
|
|
incident = db.get(RiskIncident, incident_id)
|
|
if incident is None:
|
|
raise HTTPException(status_code=404, detail="风险事件不存在")
|
|
if kind and incident.rule_code != repo.KIND_TO_RULE[kind]:
|
|
raise HTTPException(status_code=404, detail="风险事件类型不匹配")
|
|
return incident
|
|
|
|
|
|
@router.get("/summary", response_model=RiskMonitorSummary, summary="风控监控顶部卡片")
|
|
def get_summary(db: AdminDb) -> RiskMonitorSummary:
|
|
return RiskMonitorSummary(**repo.summary(db))
|
|
|
|
|
|
@router.get("/rules", response_model=RiskRuleConfig, summary="读取风控规则阈值")
|
|
def get_rules(db: AdminDb) -> RiskRuleConfig:
|
|
return _rule_config(db)
|
|
|
|
|
|
@router.patch("/rules", response_model=RiskRuleConfig, summary="修改风控规则并即时重算")
|
|
def update_rules(
|
|
body: RiskRuleConfig,
|
|
request: Request,
|
|
admin: Annotated[object, Depends(require_role("operator", "tech"))],
|
|
db: AdminDb,
|
|
) -> RiskRuleConfig:
|
|
before = _rule_config(db).model_dump()
|
|
after = body.model_dump()
|
|
values = (
|
|
(RISK_SMS_HOURLY_THRESHOLD_KEY, body.sms_hourly_threshold),
|
|
(RISK_ONECLICK_DAILY_THRESHOLD_KEY, body.oneclick_daily_threshold),
|
|
(RISK_COMPARE_DAILY_THRESHOLD_KEY, body.compare_daily_threshold),
|
|
)
|
|
for key, value in values:
|
|
app_config.set_value(
|
|
db, key, value, admin_id=admin.id, commit=False
|
|
)
|
|
|
|
now = risk_repo.utcnow()
|
|
risk_repo.reconcile_behavior_rule(
|
|
db, rule_code=risk_repo.RULE_SMS_HOURLY, at=now, commit=False
|
|
)
|
|
risk_repo.reconcile_behavior_rule(
|
|
db, rule_code=risk_repo.RULE_ONECLICK_DAILY, at=now, commit=False
|
|
)
|
|
risk_repo.reconcile_compare_rule(db, at=now, commit=False)
|
|
write_audit(
|
|
db,
|
|
admin,
|
|
action="risk.rules.update",
|
|
target_type="risk_rule",
|
|
target_id="all",
|
|
detail={"before": before, "after": after},
|
|
ip=get_client_ip(request),
|
|
commit=False,
|
|
)
|
|
db.commit()
|
|
return _rule_config(db)
|
|
|
|
|
|
@router.post(
|
|
"/reset",
|
|
response_model=RiskResetResponse,
|
|
summary="重置三类待处理报警并从零重新累计",
|
|
)
|
|
def reset_alerts(
|
|
request: Request,
|
|
admin: Annotated[object, Depends(require_role("operator", "tech"))],
|
|
db: AdminDb,
|
|
) -> RiskResetResponse:
|
|
reset_at = risk_repo.utcnow()
|
|
counts = repo.reset_open_alerts(db, admin_id=admin.id, reset_at=reset_at)
|
|
total = sum(counts.values())
|
|
write_audit(
|
|
db,
|
|
admin,
|
|
action="risk.alerts.reset",
|
|
target_type="risk_monitor",
|
|
target_id="all",
|
|
detail={
|
|
"reset_at": reset_at.isoformat(),
|
|
"reset_incident_count": total,
|
|
"reset_counts": counts,
|
|
"restrictions_changed": False,
|
|
},
|
|
ip=get_client_ip(request),
|
|
commit=False,
|
|
)
|
|
db.commit()
|
|
return RiskResetResponse(
|
|
reset_at=reset_at,
|
|
reset_incident_count=total,
|
|
reset_counts=counts,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/incidents/{kind}",
|
|
response_model=RiskIncidentPage,
|
|
summary="按短信/一键登录/比价读取风险事件",
|
|
)
|
|
def get_incidents(
|
|
kind: RiskKind,
|
|
db: AdminDb,
|
|
incident_status: Annotated[
|
|
Literal["open", "ignored", "blocked", "resolved", "all"], Query(alias="status")
|
|
] = "open",
|
|
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
|
cursor: Annotated[int, Query(ge=0)] = 0,
|
|
) -> RiskIncidentPage:
|
|
if kind == "compare":
|
|
repo.sync_today_compare_incidents(db)
|
|
items, next_cursor, total = repo.list_incidents(
|
|
db, kind=kind, status=incident_status, limit=limit, cursor=cursor
|
|
)
|
|
return RiskIncidentPage(items=items, next_cursor=next_cursor, total=total)
|
|
|
|
|
|
@router.get(
|
|
"/incidents/{kind}/{incident_id}/details",
|
|
response_model=RiskDetailPage,
|
|
summary="风险事件当期事实明细",
|
|
)
|
|
def get_incident_details(
|
|
kind: RiskKind,
|
|
incident_id: int,
|
|
db: AdminDb,
|
|
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
|
cursor: Annotated[int, Query(ge=0)] = 0,
|
|
) -> RiskDetailPage:
|
|
incident = _incident_or_404(db, incident_id, kind)
|
|
return RiskDetailPage(
|
|
**repo.incident_details(
|
|
db, incident=incident, kind=kind, limit=limit, cursor=cursor
|
|
)
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/incidents/{incident_id}/ignore",
|
|
response_model=RiskActionResponse,
|
|
summary="忽略风险事件",
|
|
)
|
|
def ignore_incident(
|
|
incident_id: int,
|
|
body: RiskActionRequest,
|
|
request: Request,
|
|
admin: Annotated[object, Depends(require_role("operator", "tech"))],
|
|
db: AdminDb,
|
|
) -> RiskActionResponse:
|
|
incident = _incident_or_404(db, incident_id)
|
|
if incident.status != "open":
|
|
raise HTTPException(status_code=409, detail="该风险事件已处理")
|
|
incident.status = "ignored"
|
|
incident.action_reason = body.reason.strip() or "管理员确认忽略"
|
|
incident.handled_by = admin.id
|
|
incident.handled_at = risk_repo.utcnow()
|
|
write_audit(
|
|
db,
|
|
admin,
|
|
action="risk.incident.ignore",
|
|
target_type="risk_incident",
|
|
target_id=incident.id,
|
|
detail={
|
|
"rule_code": incident.rule_code,
|
|
"subject_type": incident.subject_type,
|
|
"subject_id": incident.subject_id,
|
|
"reason": incident.action_reason,
|
|
},
|
|
ip=get_client_ip(request),
|
|
commit=False,
|
|
)
|
|
db.commit()
|
|
return RiskActionResponse(incident_id=incident.id, status=incident.status)
|
|
|
|
|
|
@router.post(
|
|
"/incidents/{incident_id}/block",
|
|
response_model=RiskActionResponse,
|
|
summary="封禁风险主体",
|
|
)
|
|
def block_incident(
|
|
incident_id: int,
|
|
body: RiskActionRequest,
|
|
request: Request,
|
|
admin: Annotated[object, Depends(require_role("operator", "tech"))],
|
|
db: AdminDb,
|
|
) -> RiskActionResponse:
|
|
incident = _incident_or_404(db, incident_id)
|
|
if incident.status not in ("open", "blocked"):
|
|
raise HTTPException(status_code=409, detail="该风险事件已处理")
|
|
if incident.subject_type == "device":
|
|
if incident.subject_id.startswith("legacy-ip:"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="旧客户端未上报设备ID,不能执行设备封禁",
|
|
)
|
|
scope = risk_repo.SCOPE_AUTH_DEVICE
|
|
elif incident.subject_type == "user":
|
|
scope = risk_repo.SCOPE_ECONOMIC_ACCOUNT
|
|
else: # pragma: no cover - 当前三条规则只会生成 device/user
|
|
raise HTTPException(status_code=400, detail="该主体类型暂不支持封禁")
|
|
|
|
reason = body.reason.strip() or "风控规则命中"
|
|
restriction = risk_repo.activate_restriction(
|
|
db,
|
|
subject_type=incident.subject_type,
|
|
subject_id=incident.subject_id,
|
|
scope=scope,
|
|
reason=reason,
|
|
incident_id=incident.id,
|
|
admin_id=admin.id,
|
|
)
|
|
incident.status = "blocked"
|
|
incident.action_reason = reason
|
|
incident.handled_by = admin.id
|
|
incident.handled_at = risk_repo.utcnow()
|
|
write_audit(
|
|
db,
|
|
admin,
|
|
action="risk.subject.block",
|
|
target_type=incident.subject_type,
|
|
target_id=incident.subject_id,
|
|
detail={
|
|
"incident_id": incident.id,
|
|
"rule_code": incident.rule_code,
|
|
"scope": scope,
|
|
"reason": reason,
|
|
},
|
|
ip=get_client_ip(request),
|
|
commit=False,
|
|
)
|
|
db.commit()
|
|
return RiskActionResponse(
|
|
incident_id=incident.id,
|
|
status=incident.status,
|
|
restriction_id=restriction.id,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/restrictions/{restriction_id}/revoke",
|
|
response_model=RestrictionRevokeResponse,
|
|
summary="解除主体限制",
|
|
)
|
|
def revoke_restriction(
|
|
restriction_id: int,
|
|
request: Request,
|
|
admin: Annotated[object, Depends(require_role("operator", "tech"))],
|
|
db: AdminDb,
|
|
) -> RestrictionRevokeResponse:
|
|
restriction = db.get(SubjectRestriction, restriction_id)
|
|
if restriction is None:
|
|
raise HTTPException(status_code=404, detail="限制记录不存在")
|
|
if restriction.active:
|
|
risk_repo.revoke_restriction(db, restriction=restriction, admin_id=admin.id)
|
|
if restriction.incident_id:
|
|
incident = db.get(RiskIncident, restriction.incident_id)
|
|
if incident and incident.status == "blocked":
|
|
incident.status = "resolved"
|
|
incident.handled_by = admin.id
|
|
incident.handled_at = risk_repo.utcnow()
|
|
write_audit(
|
|
db,
|
|
admin,
|
|
action="risk.subject.unblock",
|
|
target_type=restriction.subject_type,
|
|
target_id=restriction.subject_id,
|
|
detail={"restriction_id": restriction.id, "scope": restriction.scope},
|
|
ip=get_client_ip(request),
|
|
commit=False,
|
|
)
|
|
db.commit()
|
|
return RestrictionRevokeResponse(restriction_id=restriction.id)
|