"""风控监控:统计、风险事件列表/详情及忽略、封禁、解封。""" 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)