功能:新增风控监控与处置能力 (#174)
## 修改内容 - 新增短信、一键登录、比价三类风控事件与规则聚合 - 新增管理员忽略、封禁、解封、重置报警和阈值配置接口 - 风险列表返回当前有效限制的 restriction_id,供后台已封禁视图解除 - 在短信/一键登录、比价、任务领奖、提现链路接入风险记录与限制 - 新增通用行为流水、风险事件、主体限制模型及 Alembic 迁移 - 新增本地演示数据脚本与风控测试 ## 验证 - ruff check:通过 - Alembic 全新 SQLite upgrade head / downgrade -1:通过 - 风控测试:通过,覆盖封禁列表 restriction_id 与解除链路 - 全量测试:532 passed,8 failed;其中 7 项在干净 origin/main 独立复现,另 1 项独立复跑通过,未发现本分支新增回归 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: #174 Co-authored-by: linkeyu <linkeyu@wonderable.ai> Co-committed-by: linkeyu <linkeyu@wonderable.ai>
This commit was merged in pull request #174.
This commit is contained in:
+4
-2
@@ -17,6 +17,7 @@ from app.admin.routers.ad_audit import router as ad_audit_router
|
||||
from app.admin.routers.ad_config import router as ad_config_router
|
||||
from app.admin.routers.ad_revenue import router as ad_revenue_router
|
||||
from app.admin.routers.admins import router as admins_router
|
||||
from app.admin.routers.analytics_health import router as analytics_health_router
|
||||
from app.admin.routers.audit import router as audit_router
|
||||
from app.admin.routers.auth import router as auth_router
|
||||
from app.admin.routers.comparison import router as comparison_router
|
||||
@@ -25,8 +26,6 @@ from app.admin.routers.coupon_data import router as coupon_data_router
|
||||
from app.admin.routers.cps import router as cps_router
|
||||
from app.admin.routers.dashboard import router as dashboard_router
|
||||
from app.admin.routers.device_liveness import router as device_liveness_router
|
||||
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
|
||||
from app.admin.routers.analytics_health import router as analytics_health_router
|
||||
from app.admin.routers.event_logs import router as event_logs_router
|
||||
from app.admin.routers.feedback import router as feedback_router
|
||||
from app.admin.routers.feedback_qr import router as feedback_qr_router
|
||||
@@ -34,7 +33,9 @@ from app.admin.routers.guide_video import router as guide_video_router
|
||||
from app.admin.routers.huawei_review import router as huawei_review_router
|
||||
from app.admin.routers.onboarding import router as onboarding_router
|
||||
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
|
||||
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
|
||||
from app.admin.routers.price_report import router as price_report_router
|
||||
from app.admin.routers.risk_monitor import router as risk_monitor_router
|
||||
from app.admin.routers.roles import router as roles_router
|
||||
from app.admin.routers.users import router as users_router
|
||||
from app.admin.routers.wallet import router as wallet_router
|
||||
@@ -101,6 +102,7 @@ admin_app.include_router(onboarding_router)
|
||||
admin_app.include_router(wallet_router)
|
||||
admin_app.include_router(withdraw_router)
|
||||
admin_app.include_router(price_report_router)
|
||||
admin_app.include_router(risk_monitor_router)
|
||||
admin_app.include_router(feedback_router)
|
||||
admin_app.include_router(event_logs_router)
|
||||
admin_app.include_router(analytics_health_router)
|
||||
|
||||
@@ -34,6 +34,7 @@ PERMISSION_CATALOG: list[dict] = [
|
||||
{"key": "users", "label": "用户管理"},
|
||||
]},
|
||||
{"group": "监控审计", "pages": [
|
||||
{"key": "risk-monitor", "label": "风控监控"},
|
||||
{"key": "device-liveness", "label": "设备存活"},
|
||||
{"key": "analytics-health", "label": "埋点成功率"},
|
||||
{"key": "event-logs", "label": "埋点日志"},
|
||||
@@ -55,13 +56,13 @@ BUILTIN_ROLES: list[dict] = [
|
||||
{"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []},
|
||||
{"name": "operator", "label": "运营", "pages": [
|
||||
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
|
||||
"cps", "device-liveness", "price-reports", "feedbacks", "huawei-review",
|
||||
"cps", "risk-monitor", "device-liveness", "price-reports", "feedbacks", "huawei-review",
|
||||
]},
|
||||
{"name": "finance", "label": "财务", "pages": [
|
||||
"dashboard", "ad-revenue-report", "cps", "withdraws",
|
||||
]},
|
||||
{"name": "tech", "label": "技术", "pages": [
|
||||
"dashboard", "device-liveness", "analytics-health", "config", "ad-revenue", "huawei-review",
|
||||
"dashboard", "risk-monitor", "device-liveness", "analytics-health", "config", "ad-revenue", "huawei-review",
|
||||
"event-logs", "audit-logs",
|
||||
]},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
"""风控监控读模型:聚合通用行为流水、风险事件与比价记录。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.device import DeviceLiveness
|
||||
from app.models.risk import BehaviorEvent, RiskIncident
|
||||
from app.models.user import User
|
||||
from app.repositories import risk as risk_repo
|
||||
|
||||
KIND_TO_RULE = {
|
||||
"sms": risk_repo.RULE_SMS_HOURLY,
|
||||
"oneclick": risk_repo.RULE_ONECLICK_DAILY,
|
||||
"compare": risk_repo.RULE_COMPARE_DAILY,
|
||||
}
|
||||
RULE_TO_KIND = {rule: kind for kind, rule in KIND_TO_RULE.items()}
|
||||
|
||||
|
||||
def _day_bounds(now: datetime | None = None) -> tuple[str, datetime, datetime]:
|
||||
current = (now or datetime.now(UTC)).astimezone(risk_repo.CN_TZ)
|
||||
start = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = start + timedelta(days=1)
|
||||
return (
|
||||
start.strftime("%Y-%m-%d"),
|
||||
start.astimezone(UTC),
|
||||
end.astimezone(UTC),
|
||||
)
|
||||
|
||||
|
||||
def _compare_day_bounds(now: datetime | None = None) -> tuple[datetime, datetime]:
|
||||
current = (now or datetime.now(UTC)).astimezone(risk_repo.CN_TZ).replace(
|
||||
tzinfo=None
|
||||
)
|
||||
start = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return start, start + timedelta(days=1)
|
||||
|
||||
|
||||
def sync_today_compare_incidents(db: Session, now: datetime | None = None) -> None:
|
||||
risk_repo.reconcile_compare_rule(db, at=now)
|
||||
|
||||
|
||||
def reset_open_alerts(
|
||||
db: Session, *, admin_id: int, reset_at: datetime
|
||||
) -> dict[str, int]:
|
||||
"""将三类待处理报警归零,并从 reset_at 开始重新累计。"""
|
||||
rule_codes = tuple(KIND_TO_RULE.values())
|
||||
incidents = list(
|
||||
db.scalars(
|
||||
select(RiskIncident).where(
|
||||
RiskIncident.rule_code.in_(rule_codes),
|
||||
RiskIncident.status == "open",
|
||||
)
|
||||
).all()
|
||||
)
|
||||
counts = {kind: 0 for kind in KIND_TO_RULE}
|
||||
for incident in incidents:
|
||||
incident.status = "resolved"
|
||||
incident.action_reason = risk_repo.MANUAL_RESET_REASON
|
||||
incident.handled_by = admin_id
|
||||
incident.handled_at = reset_at
|
||||
counts[RULE_TO_KIND[incident.rule_code]] += 1
|
||||
risk_repo.reset_rule_baselines(
|
||||
db,
|
||||
rule_codes=rule_codes,
|
||||
reset_at=reset_at,
|
||||
admin_id=admin_id,
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def summary(db: Session, now: datetime | None = None) -> dict:
|
||||
date_key, start, end = _day_bounds(now)
|
||||
compare_start, compare_end = _compare_day_bounds(now)
|
||||
current = now or datetime.now(UTC)
|
||||
risk_repo.reconcile_behavior_rule(
|
||||
db, rule_code=risk_repo.RULE_SMS_HOURLY, at=current, commit=False
|
||||
)
|
||||
risk_repo.reconcile_behavior_rule(
|
||||
db, rule_code=risk_repo.RULE_ONECLICK_DAILY, at=current, commit=False
|
||||
)
|
||||
risk_repo.reconcile_compare_rule(db, at=current, commit=False)
|
||||
db.commit()
|
||||
sms_threshold = risk_repo.get_rule_threshold(db, risk_repo.RULE_SMS_HOURLY)
|
||||
oneclick_threshold = risk_repo.get_rule_threshold(
|
||||
db, risk_repo.RULE_ONECLICK_DAILY
|
||||
)
|
||||
compare_threshold = risk_repo.get_rule_threshold(db, risk_repo.RULE_COMPARE_DAILY)
|
||||
|
||||
def _behavior_total(event_type: str, outcomes: tuple[str, ...]) -> int:
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count(BehaviorEvent.id)).where(
|
||||
BehaviorEvent.event_type == event_type,
|
||||
BehaviorEvent.outcome.in_(outcomes),
|
||||
BehaviorEvent.occurred_at >= start,
|
||||
BehaviorEvent.occurred_at < end,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
compare_total = int(
|
||||
db.scalar(
|
||||
select(func.count(ComparisonRecord.id)).where(
|
||||
ComparisonRecord.created_at >= compare_start,
|
||||
ComparisonRecord.created_at < compare_end,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
def _open_count(rule_code: str) -> int:
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count(RiskIncident.id)).where(
|
||||
RiskIncident.rule_code == rule_code,
|
||||
RiskIncident.status == "open",
|
||||
RiskIncident.window_key.like(f"{date_key}%"),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
return {
|
||||
"date": date_key,
|
||||
"updated_at": now or datetime.now(UTC),
|
||||
"cards": [
|
||||
{
|
||||
"kind": "sms",
|
||||
"alert_subject_count": _open_count(risk_repo.RULE_SMS_HOURLY),
|
||||
"today_total": _behavior_total(risk_repo.EVENT_SMS_SEND, ("success",)),
|
||||
"threshold": sms_threshold,
|
||||
"rule_text": f"单设备 1 小时 ≥ {sms_threshold} 次",
|
||||
},
|
||||
{
|
||||
"kind": "oneclick",
|
||||
"alert_subject_count": _open_count(risk_repo.RULE_ONECLICK_DAILY),
|
||||
"today_total": _behavior_total(
|
||||
risk_repo.EVENT_ONECLICK_LOGIN, ("success", "failed")
|
||||
),
|
||||
"threshold": oneclick_threshold,
|
||||
"rule_text": f"单设备当日 ≥ {oneclick_threshold} 次",
|
||||
},
|
||||
{
|
||||
"kind": "compare",
|
||||
"alert_subject_count": _open_count(risk_repo.RULE_COMPARE_DAILY),
|
||||
"today_total": compare_total,
|
||||
"threshold": compare_threshold,
|
||||
"rule_text": f"单账户当日 ≥ {compare_threshold} 次",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _latest_device_model(db: Session, subject_id: str) -> str | None:
|
||||
return db.execute(
|
||||
select(BehaviorEvent.device_model)
|
||||
.where(
|
||||
BehaviorEvent.subject_type == "device",
|
||||
BehaviorEvent.subject_id == subject_id,
|
||||
BehaviorEvent.device_model.is_not(None),
|
||||
BehaviorEvent.device_model != "",
|
||||
)
|
||||
.order_by(BehaviorEvent.occurred_at.desc(), BehaviorEvent.id.desc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _first_used_at(db: Session, subject_id: str) -> datetime | None:
|
||||
behavior_first = db.scalar(
|
||||
select(func.min(BehaviorEvent.occurred_at)).where(
|
||||
BehaviorEvent.subject_type == "device",
|
||||
BehaviorEvent.subject_id == subject_id,
|
||||
)
|
||||
)
|
||||
registered_first = db.scalar(
|
||||
select(func.min(DeviceLiveness.created_at)).where(
|
||||
DeviceLiveness.device_id == subject_id
|
||||
)
|
||||
)
|
||||
observed = [value for value in (behavior_first, registered_first) if value is not None]
|
||||
if not observed:
|
||||
return None
|
||||
|
||||
def _utc(value: datetime) -> datetime:
|
||||
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
||||
|
||||
return min(observed, key=_utc)
|
||||
|
||||
|
||||
def _common_device(db: Session, user_id: int, start: datetime, end: datetime) -> str | None:
|
||||
return db.execute(
|
||||
select(ComparisonRecord.device_id)
|
||||
.where(
|
||||
ComparisonRecord.user_id == user_id,
|
||||
ComparisonRecord.device_id.is_not(None),
|
||||
ComparisonRecord.created_at >= start,
|
||||
ComparisonRecord.created_at < end,
|
||||
)
|
||||
.group_by(ComparisonRecord.device_id)
|
||||
.order_by(func.count(ComparisonRecord.id).desc(), ComparisonRecord.device_id.asc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_incidents(
|
||||
db: Session,
|
||||
*,
|
||||
kind: str,
|
||||
status: str = "open",
|
||||
limit: int = 20,
|
||||
cursor: int = 0,
|
||||
) -> tuple[list[dict], int | None, int]:
|
||||
rule_code = KIND_TO_RULE[kind]
|
||||
stmt = select(RiskIncident).where(RiskIncident.rule_code == rule_code)
|
||||
count_stmt = select(func.count(RiskIncident.id)).where(
|
||||
RiskIncident.rule_code == rule_code
|
||||
)
|
||||
if status != "all":
|
||||
stmt = stmt.where(RiskIncident.status == status)
|
||||
count_stmt = count_stmt.where(RiskIncident.status == status)
|
||||
total = int(db.scalar(count_stmt) or 0)
|
||||
incidents = list(
|
||||
db.scalars(
|
||||
stmt.order_by(desc(RiskIncident.triggered_at), desc(RiskIncident.id))
|
||||
.offset(cursor)
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
|
||||
user_ids = [int(i.subject_id) for i in incidents if i.subject_type == "user"]
|
||||
users = {
|
||||
u.id: u
|
||||
for u in db.scalars(select(User).where(User.id.in_(user_ids))).all()
|
||||
} if user_ids else {}
|
||||
|
||||
items: list[dict] = []
|
||||
for incident in incidents:
|
||||
item = {
|
||||
"incident_id": incident.id,
|
||||
"kind": kind,
|
||||
"subject_type": incident.subject_type,
|
||||
"subject_id": incident.subject_id,
|
||||
"window_start": incident.window_start,
|
||||
"window_end": incident.window_end,
|
||||
"first_event_at": incident.first_event_at,
|
||||
"triggered_at": incident.triggered_at,
|
||||
"last_event_at": incident.last_event_at,
|
||||
"event_count": incident.event_count,
|
||||
"status": incident.status,
|
||||
}
|
||||
if incident.subject_type == "device":
|
||||
restriction = risk_repo.get_active_restriction(
|
||||
db,
|
||||
subject_type="device",
|
||||
subject_id=incident.subject_id,
|
||||
scope=risk_repo.SCOPE_AUTH_DEVICE,
|
||||
)
|
||||
item["device_model"] = _latest_device_model(db, incident.subject_id)
|
||||
item["first_used_at"] = _first_used_at(db, incident.subject_id)
|
||||
item["restricted"] = restriction is not None
|
||||
item["restriction_id"] = restriction.id if restriction else None
|
||||
else:
|
||||
uid = int(incident.subject_id)
|
||||
user = users.get(uid)
|
||||
restriction = risk_repo.get_active_restriction(
|
||||
db,
|
||||
subject_type="user",
|
||||
subject_id=incident.subject_id,
|
||||
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
|
||||
)
|
||||
item.update(
|
||||
{
|
||||
"user_id": uid,
|
||||
"phone": user.phone if user else None,
|
||||
"registered_at": user.created_at if user else None,
|
||||
"common_device_id": _common_device(
|
||||
db, uid, incident.window_start, incident.window_end
|
||||
),
|
||||
"restricted": restriction is not None,
|
||||
"restriction_id": restriction.id if restriction else None,
|
||||
}
|
||||
)
|
||||
items.append(item)
|
||||
next_cursor = cursor + len(items) if cursor + len(items) < total else None
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
def _page_events(
|
||||
db: Session,
|
||||
incident: RiskIncident,
|
||||
*,
|
||||
event_type: str,
|
||||
outcomes: tuple[str, ...] | None,
|
||||
limit: int,
|
||||
cursor: int,
|
||||
) -> tuple[list[BehaviorEvent], int]:
|
||||
filters = [
|
||||
BehaviorEvent.event_type == event_type,
|
||||
BehaviorEvent.subject_type == incident.subject_type,
|
||||
BehaviorEvent.subject_id == incident.subject_id,
|
||||
BehaviorEvent.occurred_at >= incident.window_start,
|
||||
BehaviorEvent.occurred_at < incident.window_end,
|
||||
]
|
||||
if outcomes:
|
||||
filters.append(BehaviorEvent.outcome.in_(outcomes))
|
||||
total = int(db.scalar(select(func.count(BehaviorEvent.id)).where(*filters)) or 0)
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(BehaviorEvent)
|
||||
.where(*filters)
|
||||
.order_by(BehaviorEvent.occurred_at.desc(), BehaviorEvent.id.desc())
|
||||
.offset(cursor)
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
return rows, total
|
||||
|
||||
|
||||
def _user_map(db: Session, user_ids: set[int]) -> dict[int, User]:
|
||||
if not user_ids:
|
||||
return {}
|
||||
return {u.id: u for u in db.scalars(select(User).where(User.id.in_(user_ids))).all()}
|
||||
|
||||
|
||||
def _distinct_event_users(
|
||||
db: Session,
|
||||
incident: RiskIncident,
|
||||
*,
|
||||
event_type: str,
|
||||
outcomes: tuple[str, ...],
|
||||
) -> int:
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count(func.distinct(BehaviorEvent.user_id))).where(
|
||||
BehaviorEvent.event_type == event_type,
|
||||
BehaviorEvent.subject_type == incident.subject_type,
|
||||
BehaviorEvent.subject_id == incident.subject_id,
|
||||
BehaviorEvent.outcome.in_(outcomes),
|
||||
BehaviorEvent.user_id.is_not(None),
|
||||
BehaviorEvent.occurred_at >= incident.window_start,
|
||||
BehaviorEvent.occurred_at < incident.window_end,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def _sms_distinct_accounts(db: Session, incident: RiskIncident) -> int:
|
||||
"""统计整个告警窗口内,确实完成本设备短信验证的不同账号数。"""
|
||||
send_rows = db.execute(
|
||||
select(BehaviorEvent.phone, BehaviorEvent.occurred_at).where(
|
||||
BehaviorEvent.event_type == risk_repo.EVENT_SMS_SEND,
|
||||
BehaviorEvent.subject_type == incident.subject_type,
|
||||
BehaviorEvent.subject_id == incident.subject_id,
|
||||
BehaviorEvent.outcome == "success",
|
||||
BehaviorEvent.phone.is_not(None),
|
||||
BehaviorEvent.occurred_at >= incident.window_start,
|
||||
BehaviorEvent.occurred_at < incident.window_end,
|
||||
)
|
||||
).all()
|
||||
phones = {phone for phone, _ in send_rows}
|
||||
if not phones:
|
||||
return 0
|
||||
login_rows = db.execute(
|
||||
select(
|
||||
BehaviorEvent.user_id,
|
||||
BehaviorEvent.phone,
|
||||
BehaviorEvent.occurred_at,
|
||||
)
|
||||
.where(
|
||||
BehaviorEvent.event_type == risk_repo.EVENT_SMS_LOGIN,
|
||||
BehaviorEvent.subject_id == incident.subject_id,
|
||||
BehaviorEvent.phone.in_(phones),
|
||||
BehaviorEvent.user_id.is_not(None),
|
||||
BehaviorEvent.outcome == "success",
|
||||
BehaviorEvent.occurred_at >= incident.window_start,
|
||||
BehaviorEvent.occurred_at
|
||||
< incident.window_end + timedelta(seconds=settings.SMS_CODE_TTL_SEC),
|
||||
)
|
||||
.order_by(BehaviorEvent.occurred_at.asc(), BehaviorEvent.id.asc())
|
||||
).all()
|
||||
matched_users = {
|
||||
user_id
|
||||
for user_id, phone, login_at in login_rows
|
||||
if any(
|
||||
sent_phone == phone
|
||||
and sent_at <= login_at
|
||||
and login_at
|
||||
<= sent_at + timedelta(seconds=settings.SMS_CODE_TTL_SEC)
|
||||
for sent_phone, sent_at in send_rows
|
||||
)
|
||||
}
|
||||
return len(matched_users)
|
||||
|
||||
|
||||
def incident_details(
|
||||
db: Session,
|
||||
*,
|
||||
incident: RiskIncident,
|
||||
kind: str,
|
||||
limit: int,
|
||||
cursor: int,
|
||||
) -> dict:
|
||||
if kind == "sms":
|
||||
rows, total = _page_events(
|
||||
db,
|
||||
incident,
|
||||
event_type=risk_repo.EVENT_SMS_SEND,
|
||||
outcomes=("success",),
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
phone_values = {r.phone for r in rows if r.phone}
|
||||
login_rows = list(
|
||||
db.scalars(
|
||||
select(BehaviorEvent)
|
||||
.where(
|
||||
BehaviorEvent.event_type == risk_repo.EVENT_SMS_LOGIN,
|
||||
BehaviorEvent.subject_id == incident.subject_id,
|
||||
BehaviorEvent.phone.in_(phone_values),
|
||||
BehaviorEvent.outcome == "success",
|
||||
BehaviorEvent.occurred_at >= incident.window_start,
|
||||
BehaviorEvent.occurred_at
|
||||
< incident.window_end + timedelta(seconds=settings.SMS_CODE_TTL_SEC),
|
||||
)
|
||||
.order_by(BehaviorEvent.occurred_at.asc(), BehaviorEvent.id.asc())
|
||||
).all()
|
||||
) if phone_values else []
|
||||
users = _user_map(db, {r.user_id for r in login_rows if r.user_id is not None})
|
||||
items = []
|
||||
for row in rows:
|
||||
match = next(
|
||||
(
|
||||
login
|
||||
for login in login_rows
|
||||
if login.phone == row.phone
|
||||
and login.occurred_at >= row.occurred_at
|
||||
and login.occurred_at
|
||||
<= row.occurred_at + timedelta(seconds=settings.SMS_CODE_TTL_SEC)
|
||||
),
|
||||
None,
|
||||
)
|
||||
user = users.get(match.user_id) if match and match.user_id else None
|
||||
items.append(
|
||||
{
|
||||
"id": row.id,
|
||||
"occurred_at": row.occurred_at,
|
||||
"outcome": row.outcome,
|
||||
"phone": row.phone,
|
||||
"user_id": user.id if user else None,
|
||||
"username": user.username if user else None,
|
||||
"account_registered_at": user.created_at if user else None,
|
||||
"verified": match is not None,
|
||||
}
|
||||
)
|
||||
distinct_accounts = _sms_distinct_accounts(db, incident)
|
||||
elif kind == "oneclick":
|
||||
rows, total = _page_events(
|
||||
db,
|
||||
incident,
|
||||
event_type=risk_repo.EVENT_ONECLICK_LOGIN,
|
||||
outcomes=("success", "failed"),
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
users = _user_map(db, {r.user_id for r in rows if r.user_id is not None})
|
||||
items = []
|
||||
for row in rows:
|
||||
user = users.get(row.user_id) if row.user_id else None
|
||||
items.append(
|
||||
{
|
||||
"id": row.id,
|
||||
"occurred_at": row.occurred_at,
|
||||
"outcome": row.outcome,
|
||||
"phone": row.phone,
|
||||
"user_id": row.user_id,
|
||||
"username": user.username if user else None,
|
||||
"account_registered_at": user.created_at if user else None,
|
||||
"reason": row.reason,
|
||||
}
|
||||
)
|
||||
distinct_accounts = _distinct_event_users(
|
||||
db,
|
||||
incident,
|
||||
event_type=risk_repo.EVENT_ONECLICK_LOGIN,
|
||||
outcomes=("success", "failed"),
|
||||
)
|
||||
else:
|
||||
user_id = int(incident.subject_id)
|
||||
filters = (
|
||||
ComparisonRecord.user_id == user_id,
|
||||
ComparisonRecord.created_at >= incident.window_start,
|
||||
ComparisonRecord.created_at < incident.window_end,
|
||||
)
|
||||
total = int(db.scalar(select(func.count(ComparisonRecord.id)).where(*filters)) or 0)
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(ComparisonRecord)
|
||||
.where(*filters)
|
||||
.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc())
|
||||
.offset(cursor)
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
items = []
|
||||
for row in rows:
|
||||
prices = {
|
||||
str(result.get("platform_name") or result.get("platform_id") or "未知平台"): (
|
||||
float(result["price"]) if result.get("price") is not None else None
|
||||
)
|
||||
for result in (row.comparison_results or [])
|
||||
}
|
||||
items.append(
|
||||
{
|
||||
"id": row.id,
|
||||
"occurred_at": row.created_at,
|
||||
"outcome": row.status,
|
||||
"store_name": row.store_name,
|
||||
"product_names": row.product_names,
|
||||
"prices": prices,
|
||||
"saved_amount_yuan": (
|
||||
row.saved_amount_cents / 100
|
||||
if row.saved_amount_cents is not None
|
||||
else None
|
||||
),
|
||||
"status": row.status,
|
||||
}
|
||||
)
|
||||
distinct_accounts = None
|
||||
next_cursor = cursor + len(items) if cursor + len(items) < total else None
|
||||
return {
|
||||
"kind": kind,
|
||||
"incident_id": incident.id,
|
||||
"event_count": incident.event_count,
|
||||
"distinct_accounts": distinct_accounts,
|
||||
"items": items,
|
||||
"next_cursor": next_cursor,
|
||||
"total": total,
|
||||
}
|
||||
@@ -30,6 +30,12 @@ def _validate(key: str, value: Any) -> None:
|
||||
if t == "int":
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise ValueError("需为非负整数")
|
||||
minimum = CONFIG_DEFS[key].get("min")
|
||||
maximum = CONFIG_DEFS[key].get("max")
|
||||
if minimum is not None and value < minimum:
|
||||
raise ValueError(f"不能小于 {minimum}")
|
||||
if maximum is not None and value > maximum:
|
||||
raise ValueError(f"不能大于 {maximum}")
|
||||
elif t == "int_list":
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ValueError("需为非空整数列表")
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""风控监控:统计、风险事件列表/详情及忽略、封禁、解封。"""
|
||||
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)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""风控监控后台接口 schema。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RiskSummaryCard(BaseModel):
|
||||
kind: str
|
||||
alert_subject_count: int
|
||||
today_total: int
|
||||
threshold: int
|
||||
rule_text: str
|
||||
|
||||
|
||||
class RiskMonitorSummary(BaseModel):
|
||||
date: str
|
||||
updated_at: datetime
|
||||
cards: list[RiskSummaryCard]
|
||||
|
||||
|
||||
class RiskRuleConfig(BaseModel):
|
||||
sms_hourly_threshold: int = Field(ge=1, le=5)
|
||||
oneclick_daily_threshold: int = Field(ge=1, le=100_000)
|
||||
compare_daily_threshold: int = Field(ge=1, le=100)
|
||||
|
||||
|
||||
class RiskIncidentItem(BaseModel):
|
||||
incident_id: int
|
||||
kind: str
|
||||
subject_type: str
|
||||
subject_id: str
|
||||
device_model: str | None = None
|
||||
first_used_at: datetime | None = None
|
||||
phone: str | None = None
|
||||
user_id: int | None = None
|
||||
registered_at: datetime | None = None
|
||||
common_device_id: str | None = None
|
||||
window_start: datetime
|
||||
window_end: datetime
|
||||
first_event_at: datetime
|
||||
triggered_at: datetime
|
||||
last_event_at: datetime
|
||||
event_count: int
|
||||
status: str
|
||||
restricted: bool = False
|
||||
restriction_id: int | None = None
|
||||
|
||||
|
||||
class RiskIncidentPage(BaseModel):
|
||||
items: list[RiskIncidentItem]
|
||||
next_cursor: int | None = None
|
||||
total: int
|
||||
|
||||
|
||||
class RiskDetailItem(BaseModel):
|
||||
id: int
|
||||
occurred_at: datetime
|
||||
outcome: str
|
||||
phone: str | None = None
|
||||
user_id: int | None = None
|
||||
username: str | None = None
|
||||
account_registered_at: datetime | None = None
|
||||
verified: bool | None = None
|
||||
reason: str | None = None
|
||||
store_name: str | None = None
|
||||
product_names: str | None = None
|
||||
prices: dict[str, float | None] | None = None
|
||||
saved_amount_yuan: float | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class RiskDetailPage(BaseModel):
|
||||
kind: str
|
||||
incident_id: int
|
||||
event_count: int
|
||||
distinct_accounts: int | None = None
|
||||
items: list[RiskDetailItem]
|
||||
next_cursor: int | None = None
|
||||
total: int
|
||||
|
||||
|
||||
class RiskActionRequest(BaseModel):
|
||||
reason: str = Field("", max_length=256)
|
||||
|
||||
|
||||
class RiskActionResponse(BaseModel):
|
||||
ok: bool = True
|
||||
incident_id: int
|
||||
status: str
|
||||
restriction_id: int | None = None
|
||||
|
||||
|
||||
class RiskResetResponse(BaseModel):
|
||||
ok: bool = True
|
||||
reset_at: datetime
|
||||
reset_incident_count: int
|
||||
reset_counts: dict[str, int]
|
||||
|
||||
|
||||
class RestrictionRevokeResponse(BaseModel):
|
||||
ok: bool = True
|
||||
restriction_id: int
|
||||
Reference in New Issue
Block a user