7bf04f2655
## 修改内容 - 新增短信、一键登录、比价三类风控事件与规则聚合 - 新增管理员忽略、封禁、解封、重置报警和阈值配置接口 - 风险列表返回当前有效限制的 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>
542 lines
20 KiB
Python
542 lines
20 KiB
Python
"""风控监控:三类规则、明细及处置闭环。"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.admin.main import admin_app
|
|
from app.admin.repositories import admin_user as admin_repo
|
|
from app.api.v1 import compare as compare_api
|
|
from app.core.config_schema import (
|
|
RISK_COMPARE_DAILY_THRESHOLD_KEY,
|
|
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
|
|
RISK_SMS_HOURLY_THRESHOLD_KEY,
|
|
)
|
|
from app.core.security import issue_token_pair
|
|
from app.db.session import SessionLocal
|
|
from app.models.app_config import AppConfig
|
|
from app.models.comparison import ComparisonRecord
|
|
from app.models.device import DeviceLiveness
|
|
from app.models.risk import RiskIncident
|
|
from app.repositories import risk as risk_repo
|
|
from app.repositories import user as user_repo
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_risk_rule_config():
|
|
keys = (
|
|
RISK_SMS_HOURLY_THRESHOLD_KEY,
|
|
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
|
|
RISK_COMPARE_DAILY_THRESHOLD_KEY,
|
|
risk_repo.RISK_RESET_BASELINES_KEY,
|
|
)
|
|
with SessionLocal() as db:
|
|
db.query(AppConfig).filter(AppConfig.key.in_(keys)).delete(
|
|
synchronize_session=False
|
|
)
|
|
db.commit()
|
|
yield
|
|
with SessionLocal() as db:
|
|
db.query(AppConfig).filter(AppConfig.key.in_(keys)).delete(
|
|
synchronize_session=False
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def _admin_token() -> str:
|
|
username = "risk_monitor_admin"
|
|
with SessionLocal() as db:
|
|
if admin_repo.get_by_username(db, username) is None:
|
|
admin_repo.create_admin(
|
|
db, username=username, password="risk-pass-123", role="super_admin"
|
|
)
|
|
with TestClient(admin_app) as client:
|
|
response = client.post(
|
|
"/admin/api/auth/login",
|
|
json={"username": username, "password": "risk-pass-123"},
|
|
)
|
|
assert response.status_code == 200
|
|
return response.json()["access_token"]
|
|
|
|
|
|
def _headers() -> dict[str, str]:
|
|
return {"Authorization": f"Bearer {_admin_token()}"}
|
|
|
|
|
|
def _seed_incidents() -> tuple[int, int, int, int, str]:
|
|
suffix = uuid4().hex[:8]
|
|
sms_subject = f"risk_sms_{suffix}"
|
|
oneclick_subject = f"risk_oneclick_{suffix}"
|
|
now = (risk_repo.utcnow() - timedelta(minutes=5)).replace(microsecond=0)
|
|
with SessionLocal() as db:
|
|
user = user_repo.upsert_user_for_login(
|
|
db,
|
|
phone=f"139{int(suffix, 16) % 100_000_000:08d}",
|
|
register_channel="sms",
|
|
)
|
|
second_user = user_repo.upsert_user_for_login(
|
|
db,
|
|
phone=f"137{int(suffix, 16) % 100_000_000:08d}",
|
|
register_channel="sms",
|
|
)
|
|
db.add(
|
|
DeviceLiveness(
|
|
user_id=user.id,
|
|
device_id=sms_subject,
|
|
created_at=now - timedelta(days=10),
|
|
)
|
|
)
|
|
for index in range(5):
|
|
risk_repo.record_behavior_event(
|
|
db,
|
|
event_type=risk_repo.EVENT_SMS_SEND,
|
|
subject_type="device",
|
|
subject_id=sms_subject,
|
|
device_id=sms_subject,
|
|
device_model="OPPO A5",
|
|
phone=f"13877771{index:03d}",
|
|
outcome="success",
|
|
occurred_at=now + timedelta(seconds=index),
|
|
evaluate_rule=risk_repo.RULE_SMS_HOURLY,
|
|
)
|
|
risk_repo.record_behavior_event(
|
|
db,
|
|
event_type=risk_repo.EVENT_SMS_LOGIN,
|
|
subject_type="device",
|
|
subject_id=sms_subject,
|
|
user_id=user.id,
|
|
device_id=sms_subject,
|
|
device_model="OPPO A5",
|
|
phone="13877771000",
|
|
outcome="success",
|
|
occurred_at=now + timedelta(seconds=30),
|
|
)
|
|
risk_repo.record_behavior_event(
|
|
db,
|
|
event_type=risk_repo.EVENT_SMS_LOGIN,
|
|
subject_type="device",
|
|
subject_id=sms_subject,
|
|
user_id=second_user.id,
|
|
device_id=sms_subject,
|
|
device_model="OPPO A5",
|
|
phone="13877771001",
|
|
outcome="success",
|
|
occurred_at=now + timedelta(seconds=31),
|
|
)
|
|
|
|
for index in range(21):
|
|
risk_repo.record_behavior_event(
|
|
db,
|
|
event_type=risk_repo.EVENT_ONECLICK_LOGIN,
|
|
subject_type="device",
|
|
subject_id=oneclick_subject,
|
|
user_id=second_user.id if index == 20 else user.id,
|
|
device_id=oneclick_subject,
|
|
device_model="OPPO Reno",
|
|
phone=user.phone,
|
|
outcome="success" if index % 3 else "failed",
|
|
reason="mock provider failure" if index % 3 == 0 else None,
|
|
occurred_at=now + timedelta(seconds=index),
|
|
evaluate_rule=risk_repo.RULE_ONECLICK_DAILY,
|
|
)
|
|
|
|
local_now = now.astimezone(risk_repo.CN_TZ).replace(tzinfo=None)
|
|
for index in range(100):
|
|
db.add(
|
|
ComparisonRecord(
|
|
user_id=user.id,
|
|
device_id="risk_compare_device",
|
|
trace_id=f"risk-monitor-{suffix}-{index}",
|
|
store_name="测试门店",
|
|
product_names="测试菜品",
|
|
status="success",
|
|
items=[{"name": "测试菜品", "qty": 1}],
|
|
comparison_results=[
|
|
{"platform_id": "meituan", "platform_name": "美团", "price": 12.5},
|
|
{"platform_id": "taobao", "platform_name": "淘宝", "price": 11.8},
|
|
],
|
|
skipped_dish_names=[],
|
|
saved_amount_cents=70,
|
|
created_at=local_now + timedelta(seconds=index),
|
|
)
|
|
)
|
|
db.commit()
|
|
risk_repo.sync_compare_incident(db, user_id=user.id, at=local_now)
|
|
|
|
incidents = {
|
|
incident.rule_code: incident.id
|
|
for incident in db.query(RiskIncident)
|
|
.filter(
|
|
RiskIncident.subject_id.in_(
|
|
(sms_subject, oneclick_subject, str(user.id))
|
|
)
|
|
)
|
|
.all()
|
|
}
|
|
return (
|
|
incidents[risk_repo.RULE_SMS_HOURLY],
|
|
incidents[risk_repo.RULE_ONECLICK_DAILY],
|
|
incidents[risk_repo.RULE_COMPARE_DAILY],
|
|
user.id,
|
|
sms_subject,
|
|
)
|
|
|
|
|
|
def test_risk_monitor_summary_lists_and_details() -> None:
|
|
sms_id, oneclick_id, compare_id, _, _ = _seed_incidents()
|
|
headers = _headers()
|
|
with TestClient(admin_app) as client:
|
|
summary = client.get("/admin/api/risk-monitor/summary", headers=headers)
|
|
assert summary.status_code == 200
|
|
cards = {card["kind"]: card for card in summary.json()["cards"]}
|
|
assert cards["sms"]["alert_subject_count"] >= 1
|
|
assert cards["sms"]["today_total"] >= 5
|
|
assert cards["oneclick"]["alert_subject_count"] >= 1
|
|
assert cards["oneclick"]["today_total"] >= 20
|
|
assert cards["compare"]["alert_subject_count"] >= 1
|
|
assert cards["compare"]["today_total"] >= 100
|
|
|
|
sms_list = client.get("/admin/api/risk-monitor/incidents/sms", headers=headers)
|
|
assert sms_list.status_code == 200
|
|
sms_item = next(
|
|
item for item in sms_list.json()["items"] if item["incident_id"] == sms_id
|
|
)
|
|
assert sms_item["device_model"] == "OPPO A5"
|
|
assert sms_item["event_count"] == 5
|
|
assert sms_item["first_used_at"].startswith(
|
|
(risk_repo.utcnow() - timedelta(days=10)).date().isoformat()
|
|
)
|
|
|
|
sms_detail = client.get(
|
|
f"/admin/api/risk-monitor/incidents/sms/{sms_id}/details",
|
|
headers=headers,
|
|
)
|
|
assert sms_detail.status_code == 200
|
|
assert sms_detail.json()["total"] == 5
|
|
assert sms_detail.json()["distinct_accounts"] == 2
|
|
assert any(item["verified"] for item in sms_detail.json()["items"])
|
|
|
|
oneclick_detail = client.get(
|
|
f"/admin/api/risk-monitor/incidents/oneclick/{oneclick_id}/details?limit=1",
|
|
headers=headers,
|
|
)
|
|
assert oneclick_detail.status_code == 200
|
|
assert oneclick_detail.json()["total"] == 21
|
|
assert oneclick_detail.json()["distinct_accounts"] == 2
|
|
|
|
compare_detail = client.get(
|
|
f"/admin/api/risk-monitor/incidents/compare/{compare_id}/details",
|
|
headers=headers,
|
|
)
|
|
assert compare_detail.status_code == 200
|
|
assert compare_detail.json()["total"] == 100
|
|
assert compare_detail.json()["items"][0]["prices"]["美团"] == 12.5
|
|
|
|
|
|
def test_risk_monitor_ignore_and_block_are_enforced(client: TestClient) -> None:
|
|
sms_id, oneclick_id, compare_id, user_id, sms_subject = _seed_incidents()
|
|
headers = _headers()
|
|
with TestClient(admin_app) as admin_client:
|
|
ignored = admin_client.post(
|
|
f"/admin/api/risk-monitor/incidents/{oneclick_id}/ignore",
|
|
json={"reason": "测试确认正常"},
|
|
headers=headers,
|
|
)
|
|
assert ignored.status_code == 200
|
|
assert ignored.json()["status"] == "ignored"
|
|
|
|
blocked_device = admin_client.post(
|
|
f"/admin/api/risk-monitor/incidents/{sms_id}/block",
|
|
json={"reason": "测试设备异常"},
|
|
headers=headers,
|
|
)
|
|
assert blocked_device.status_code == 200
|
|
assert blocked_device.json()["restriction_id"]
|
|
|
|
blocked_user = admin_client.post(
|
|
f"/admin/api/risk-monitor/incidents/{compare_id}/block",
|
|
json={"reason": "测试账号异常"},
|
|
headers=headers,
|
|
)
|
|
assert blocked_user.status_code == 200
|
|
device_restriction_id = blocked_device.json()["restriction_id"]
|
|
user_restriction_id = blocked_user.json()["restriction_id"]
|
|
|
|
blocked_sms = admin_client.get(
|
|
"/admin/api/risk-monitor/incidents/sms?status=blocked",
|
|
headers=headers,
|
|
)
|
|
assert blocked_sms.status_code == 200
|
|
assert blocked_sms.json()["items"][0]["restriction_id"] == device_restriction_id
|
|
assert blocked_sms.json()["items"][0]["restricted"] is True
|
|
|
|
blocked_compare = admin_client.get(
|
|
"/admin/api/risk-monitor/incidents/compare?status=blocked",
|
|
headers=headers,
|
|
)
|
|
assert blocked_compare.status_code == 200
|
|
assert blocked_compare.json()["items"][0]["restriction_id"] == user_restriction_id
|
|
assert blocked_compare.json()["items"][0]["restricted"] is True
|
|
|
|
denied_sms = client.post(
|
|
"/api/v1/auth/sms/send",
|
|
json={
|
|
"phone": "13877779999",
|
|
"device_id": sms_subject,
|
|
"device_model": "OPPO A5",
|
|
},
|
|
)
|
|
assert denied_sms.status_code == 403
|
|
assert denied_sms.json()["detail"] == "当前设备环境异常,暂无法发送验证码"
|
|
|
|
tokens = issue_token_pair(user_id)
|
|
denied_compare = client.post(
|
|
"/api/v1/compare/start",
|
|
json={"trace_id": "risk-blocked-new-trace", "device_id": "risk_compare_device"},
|
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
|
)
|
|
assert denied_compare.status_code == 403
|
|
assert denied_compare.json()["detail"] == "账号存在异常,该功能暂不可用"
|
|
|
|
denied_compare_step = client.post(
|
|
"/api/v1/intent/recognize",
|
|
json={},
|
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
|
)
|
|
assert denied_compare_step.status_code == 403
|
|
assert denied_compare_step.json()["detail"] == "账号存在异常,该功能暂不可用"
|
|
|
|
with TestClient(admin_app) as admin_client:
|
|
assert (
|
|
admin_client.post(
|
|
f"/admin/api/risk-monitor/restrictions/{device_restriction_id}/revoke",
|
|
headers=headers,
|
|
).status_code
|
|
== 200
|
|
)
|
|
assert (
|
|
admin_client.post(
|
|
f"/admin/api/risk-monitor/restrictions/{user_restriction_id}/revoke",
|
|
headers=headers,
|
|
).status_code
|
|
== 200
|
|
)
|
|
|
|
allowed_sms = client.post(
|
|
"/api/v1/auth/sms/send",
|
|
json={
|
|
"phone": "13877779998",
|
|
"device_id": sms_subject,
|
|
"device_model": "OPPO A5",
|
|
},
|
|
)
|
|
assert allowed_sms.status_code == 200
|
|
|
|
with SessionLocal() as db:
|
|
assert not risk_repo.is_restricted(
|
|
db,
|
|
subject_type="user",
|
|
subject_id=str(user_id),
|
|
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
|
|
)
|
|
assert db.get(RiskIncident, compare_id).status == "resolved"
|
|
|
|
|
|
def test_admin_can_reset_all_alerts_and_sms_restarts_from_zero() -> None:
|
|
sms_id, oneclick_id, compare_id, _, sms_subject = _seed_incidents()
|
|
headers = _headers()
|
|
|
|
with TestClient(admin_app) as client:
|
|
reset = client.post("/admin/api/risk-monitor/reset", headers=headers)
|
|
assert reset.status_code == 200
|
|
payload = reset.json()
|
|
assert payload["reset_incident_count"] >= 3
|
|
assert payload["reset_counts"]["sms"] >= 1
|
|
assert payload["reset_counts"]["oneclick"] >= 1
|
|
assert payload["reset_counts"]["compare"] >= 1
|
|
|
|
summary = client.get("/admin/api/risk-monitor/summary", headers=headers)
|
|
assert summary.status_code == 200
|
|
cards = {card["kind"]: card for card in summary.json()["cards"]}
|
|
assert all(card["alert_subject_count"] == 0 for card in cards.values())
|
|
# 重置只清待处理报警,不删除今日事实总量。
|
|
assert cards["sms"]["today_total"] >= 5
|
|
assert cards["oneclick"]["today_total"] >= 20
|
|
assert cards["compare"]["today_total"] >= 100
|
|
|
|
reset_at = datetime.fromisoformat(payload["reset_at"])
|
|
with SessionLocal() as db:
|
|
for incident_id in (sms_id, oneclick_id, compare_id):
|
|
incident = db.get(RiskIncident, incident_id)
|
|
assert incident.status == "resolved"
|
|
assert incident.action_reason == risk_repo.MANUAL_RESET_REASON
|
|
|
|
for index in range(4):
|
|
risk_repo.record_behavior_event(
|
|
db,
|
|
event_type=risk_repo.EVENT_SMS_SEND,
|
|
subject_type="device",
|
|
subject_id=sms_subject,
|
|
device_id=sms_subject,
|
|
phone=f"13688880{index:03d}",
|
|
outcome="success",
|
|
occurred_at=reset_at + timedelta(seconds=index + 1),
|
|
evaluate_rule=risk_repo.RULE_SMS_HOURLY,
|
|
)
|
|
assert db.get(RiskIncident, sms_id).status == "resolved"
|
|
|
|
risk_repo.record_behavior_event(
|
|
db,
|
|
event_type=risk_repo.EVENT_SMS_SEND,
|
|
subject_type="device",
|
|
subject_id=sms_subject,
|
|
device_id=sms_subject,
|
|
phone="13688880999",
|
|
outcome="success",
|
|
occurred_at=reset_at + timedelta(seconds=5),
|
|
evaluate_rule=risk_repo.RULE_SMS_HOURLY,
|
|
)
|
|
incident = db.get(RiskIncident, sms_id)
|
|
assert incident.status == "open"
|
|
assert incident.event_count == 5
|
|
assert incident.window_start == reset_at.replace(tzinfo=None)
|
|
|
|
|
|
def test_real_compare_harvest_creates_incident_at_threshold() -> None:
|
|
suffix = uuid4().hex[:8]
|
|
local_now = risk_repo.utcnow().astimezone(risk_repo.CN_TZ).replace(tzinfo=None)
|
|
with SessionLocal() as db:
|
|
user = user_repo.upsert_user_for_login(
|
|
db,
|
|
phone=f"136{int(suffix, 16) % 100_000_000:08d}",
|
|
register_channel="sms",
|
|
)
|
|
for index in range(99):
|
|
db.add(
|
|
ComparisonRecord(
|
|
user_id=user.id,
|
|
device_id="real-harvest-device",
|
|
trace_id=f"risk-real-harvest-{suffix}-{index}",
|
|
status="running",
|
|
created_at=local_now + timedelta(milliseconds=index),
|
|
)
|
|
)
|
|
db.commit()
|
|
user_id = user.id
|
|
|
|
compare_api._harvest_running_blocking(
|
|
f"risk-real-harvest-{suffix}-99",
|
|
user_id,
|
|
"food",
|
|
"real-harvest-device",
|
|
{"model": "OPPO A5"},
|
|
None,
|
|
)
|
|
|
|
with SessionLocal() as db:
|
|
incident = db.query(RiskIncident).filter(
|
|
RiskIncident.rule_code == risk_repo.RULE_COMPARE_DAILY,
|
|
RiskIncident.subject_id == str(user_id),
|
|
RiskIncident.window_key == local_now.strftime("%Y-%m-%d"),
|
|
).one()
|
|
assert incident.event_count == 100
|
|
|
|
|
|
def test_admin_can_edit_rules_and_current_window_is_reconciled() -> None:
|
|
suffix = uuid4().hex[:8]
|
|
subject = f"risk_dynamic_sms_{suffix}"
|
|
now = risk_repo.utcnow().replace(microsecond=0)
|
|
with SessionLocal() as db:
|
|
for index in range(3):
|
|
risk_repo.record_behavior_event(
|
|
db,
|
|
event_type=risk_repo.EVENT_SMS_SEND,
|
|
subject_type="device",
|
|
subject_id=subject,
|
|
device_id=subject,
|
|
device_model="动态规则测试机",
|
|
phone=f"13800001{index:03d}",
|
|
outcome="success",
|
|
occurred_at=now + timedelta(seconds=index),
|
|
)
|
|
|
|
headers = _headers()
|
|
with TestClient(admin_app) as client:
|
|
defaults = client.get("/admin/api/risk-monitor/rules", headers=headers)
|
|
assert defaults.status_code == 200
|
|
assert defaults.json() == {
|
|
"sms_hourly_threshold": 5,
|
|
"oneclick_daily_threshold": 20,
|
|
"compare_daily_threshold": 100,
|
|
}
|
|
|
|
lowered = client.patch(
|
|
"/admin/api/risk-monitor/rules",
|
|
headers=headers,
|
|
json={
|
|
"sms_hourly_threshold": 3,
|
|
"oneclick_daily_threshold": 20,
|
|
"compare_daily_threshold": 100,
|
|
},
|
|
)
|
|
assert lowered.status_code == 200
|
|
with SessionLocal() as db:
|
|
incident = db.query(RiskIncident).filter(
|
|
RiskIncident.rule_code == risk_repo.RULE_SMS_HOURLY,
|
|
RiskIncident.subject_id == subject,
|
|
).one()
|
|
assert incident.status == "open"
|
|
assert incident.event_count == 3
|
|
|
|
raised = client.patch(
|
|
"/admin/api/risk-monitor/rules",
|
|
headers=headers,
|
|
json={
|
|
"sms_hourly_threshold": 4,
|
|
"oneclick_daily_threshold": 20,
|
|
"compare_daily_threshold": 100,
|
|
},
|
|
)
|
|
assert raised.status_code == 200
|
|
with SessionLocal() as db:
|
|
incident = db.query(RiskIncident).filter(
|
|
RiskIncident.rule_code == risk_repo.RULE_SMS_HOURLY,
|
|
RiskIncident.subject_id == subject,
|
|
).one()
|
|
assert incident.status == "resolved"
|
|
assert incident.action_reason == risk_repo.AUTO_RESOLVED_REASON
|
|
|
|
reopened = client.patch(
|
|
"/admin/api/risk-monitor/rules",
|
|
headers=headers,
|
|
json={
|
|
"sms_hourly_threshold": 3,
|
|
"oneclick_daily_threshold": 20,
|
|
"compare_daily_threshold": 100,
|
|
},
|
|
)
|
|
assert reopened.status_code == 200
|
|
with SessionLocal() as db:
|
|
incident = db.query(RiskIncident).filter(
|
|
RiskIncident.rule_code == risk_repo.RULE_SMS_HOURLY,
|
|
RiskIncident.subject_id == subject,
|
|
).one()
|
|
assert incident.status == "open"
|
|
assert incident.triggered_at == (
|
|
now + timedelta(seconds=2)
|
|
).replace(tzinfo=None)
|
|
|
|
invalid = client.patch(
|
|
"/admin/api/risk-monitor/rules",
|
|
headers=headers,
|
|
json={
|
|
"sms_hourly_threshold": 21,
|
|
"oneclick_daily_threshold": 20,
|
|
"compare_daily_threshold": 100,
|
|
},
|
|
)
|
|
assert invalid.status_code == 422
|