功能:新增风控监控与处置能力 (#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:
@@ -0,0 +1,274 @@
|
||||
"""为本地“风控监控”页面灌入一组可重复的完整演示数据。
|
||||
|
||||
仅允许在 APP_ENV=dev 运行。脚本会重建 ``risk-demo-*`` 前缀的数据,并创建本地
|
||||
后台账号 ``risk_demo / RiskDemo123!``,方便端到端和视觉验收。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.core.config import settings
|
||||
from app.core.security import hash_password
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.risk import BehaviorEvent, RiskIncident, SubjectRestriction
|
||||
from app.repositories import risk as risk_repo
|
||||
from app.repositories import user as user_repo
|
||||
|
||||
DEMO_PREFIX = "risk-demo-"
|
||||
ADMIN_USERNAME = "risk_demo"
|
||||
ADMIN_PASSWORD = "RiskDemo123!"
|
||||
|
||||
|
||||
def _reset_demo(db) -> None:
|
||||
demo_user_ids = list(
|
||||
db.scalars(
|
||||
select(ComparisonRecord.user_id)
|
||||
.where(
|
||||
ComparisonRecord.trace_id.like(f"{DEMO_PREFIX}%"),
|
||||
ComparisonRecord.user_id.is_not(None),
|
||||
)
|
||||
.distinct()
|
||||
).all()
|
||||
)
|
||||
demo_incident_ids = list(
|
||||
db.scalars(
|
||||
select(RiskIncident.id).where(
|
||||
(RiskIncident.subject_id.like(f"{DEMO_PREFIX}%"))
|
||||
| (
|
||||
(RiskIncident.rule_code == risk_repo.RULE_COMPARE_DAILY)
|
||||
& (RiskIncident.subject_id.in_([str(value) for value in demo_user_ids]))
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if demo_incident_ids:
|
||||
db.execute(
|
||||
delete(SubjectRestriction).where(
|
||||
SubjectRestriction.incident_id.in_(demo_incident_ids)
|
||||
)
|
||||
)
|
||||
db.execute(
|
||||
delete(SubjectRestriction).where(
|
||||
SubjectRestriction.subject_id.like(f"{DEMO_PREFIX}%")
|
||||
)
|
||||
)
|
||||
db.execute(
|
||||
delete(RiskIncident).where(
|
||||
(RiskIncident.subject_id.like(f"{DEMO_PREFIX}%"))
|
||||
| (
|
||||
(RiskIncident.rule_code == risk_repo.RULE_COMPARE_DAILY)
|
||||
& (RiskIncident.subject_id.in_([str(value) for value in demo_user_ids]))
|
||||
)
|
||||
)
|
||||
)
|
||||
db.execute(
|
||||
delete(BehaviorEvent).where(
|
||||
BehaviorEvent.subject_id.like(f"{DEMO_PREFIX}%")
|
||||
)
|
||||
)
|
||||
db.execute(
|
||||
delete(ComparisonRecord).where(
|
||||
ComparisonRecord.trace_id.like(f"{DEMO_PREFIX}%")
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _ensure_admin(db) -> None:
|
||||
admin = admin_repo.get_by_username(db, ADMIN_USERNAME)
|
||||
if admin is None:
|
||||
admin_repo.create_admin(
|
||||
db,
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
role="super_admin",
|
||||
)
|
||||
return
|
||||
admin.password_hash = hash_password(ADMIN_PASSWORD)
|
||||
admin.role = "super_admin"
|
||||
admin.status = "active"
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_sms(db, now) -> None:
|
||||
alerted = [
|
||||
("risk-demo-sms-oppo-a5", "OPPO A5", "13812343001", 9),
|
||||
("risk-demo-sms-xiaomi14", "Xiaomi 14", "13812343002", 7),
|
||||
("risk-demo-sms-iphone15", "iPhone 15", "13812343003", 5),
|
||||
]
|
||||
events: list[BehaviorEvent] = []
|
||||
for device_id, model, phone, count in alerted:
|
||||
for index in range(count):
|
||||
events.append(
|
||||
BehaviorEvent(
|
||||
event_type=risk_repo.EVENT_SMS_SEND,
|
||||
subject_type="device",
|
||||
subject_id=device_id,
|
||||
device_id=device_id,
|
||||
device_model=model,
|
||||
phone=phone if index == 0 else f"138{index:08d}"[-11:],
|
||||
client_ip="127.0.0.1",
|
||||
outcome="success",
|
||||
details={"demo": True},
|
||||
occurred_at=now + timedelta(seconds=index),
|
||||
)
|
||||
)
|
||||
# 其余成功下发分散到未达阈值的设备,每台最多 4 条,确保报警设备仍严格为 3 台。
|
||||
remaining = 1284 - sum(row[3] for row in alerted)
|
||||
for index in range(remaining):
|
||||
device_no = index // 4
|
||||
device_id = f"risk-demo-sms-normal-{device_no:04d}"
|
||||
events.append(
|
||||
BehaviorEvent(
|
||||
event_type=risk_repo.EVENT_SMS_SEND,
|
||||
subject_type="device",
|
||||
subject_id=device_id,
|
||||
device_id=device_id,
|
||||
device_model="演示普通设备",
|
||||
phone=f"137{index % 100_000_000:08d}",
|
||||
client_ip="127.0.0.1",
|
||||
outcome="success",
|
||||
details={"demo": True},
|
||||
occurred_at=now + timedelta(seconds=index % 1800),
|
||||
)
|
||||
)
|
||||
db.add_all(events)
|
||||
db.flush()
|
||||
for device_id, _, phone, _ in alerted:
|
||||
risk_repo.evaluate_behavior_rule(
|
||||
db,
|
||||
rule_code=risk_repo.RULE_SMS_HOURLY,
|
||||
subject_id=device_id,
|
||||
at=now,
|
||||
)
|
||||
user = user_repo.upsert_user_for_login(
|
||||
db, phone=phone, register_channel="sms"
|
||||
)
|
||||
db.add(
|
||||
BehaviorEvent(
|
||||
event_type=risk_repo.EVENT_SMS_LOGIN,
|
||||
subject_type="device",
|
||||
subject_id=device_id,
|
||||
user_id=user.id,
|
||||
device_id=device_id,
|
||||
phone=phone,
|
||||
outcome="success",
|
||||
details={"demo": True},
|
||||
occurred_at=now + timedelta(minutes=2),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_oneclick(db, now) -> None:
|
||||
devices = [
|
||||
("risk-demo-oneclick-oppo-reno", "OPPO Reno", "13912343001", 482),
|
||||
("risk-demo-oneclick-vivo-y36", "vivo Y36", "13912343002", 481),
|
||||
]
|
||||
for device_id, model, phone, count in devices:
|
||||
user = user_repo.upsert_user_for_login(
|
||||
db, phone=phone, register_channel="jverify"
|
||||
)
|
||||
db.add_all(
|
||||
[
|
||||
BehaviorEvent(
|
||||
event_type=risk_repo.EVENT_ONECLICK_LOGIN,
|
||||
subject_type="device",
|
||||
subject_id=device_id,
|
||||
user_id=user.id,
|
||||
device_id=device_id,
|
||||
device_model=model,
|
||||
phone=phone,
|
||||
client_ip="127.0.0.1",
|
||||
outcome="failed" if index % 11 == 0 else "success",
|
||||
reason="运营商校验失败" if index % 11 == 0 else None,
|
||||
details={"demo": True},
|
||||
occurred_at=now + timedelta(seconds=index * 2),
|
||||
)
|
||||
for index in range(count)
|
||||
]
|
||||
)
|
||||
db.flush()
|
||||
risk_repo.evaluate_behavior_rule(
|
||||
db,
|
||||
rule_code=risk_repo.RULE_ONECLICK_DAILY,
|
||||
subject_id=device_id,
|
||||
at=now,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_compare(db, now) -> None:
|
||||
local_now = now.astimezone(risk_repo.CN_TZ).replace(tzinfo=None)
|
||||
accounts = [
|
||||
("13612343281", "risk-demo-compare-device-a", 1053),
|
||||
("13511106208", "risk-demo-compare-device-b", 1052),
|
||||
]
|
||||
for phone, device_id, count in accounts:
|
||||
user = user_repo.upsert_user_for_login(
|
||||
db, phone=phone, register_channel="sms"
|
||||
)
|
||||
rows = []
|
||||
for index in range(count):
|
||||
rows.append(
|
||||
ComparisonRecord(
|
||||
user_id=user.id,
|
||||
device_id=device_id,
|
||||
trace_id=f"{DEMO_PREFIX}compare-{user.id}-{index}",
|
||||
store_name=("春熙路小吃店", "科技园轻食", "万达广场烤肉")[
|
||||
index % 3
|
||||
],
|
||||
product_names=("招牌套餐", "鸡胸沙拉", "双人烤肉")[index % 3],
|
||||
status="failed" if index % 17 == 0 else "success",
|
||||
information="演示比价记录",
|
||||
items=[{"name": "演示菜品", "qty": 1}],
|
||||
comparison_results=[
|
||||
{
|
||||
"platform_id": "meituan",
|
||||
"platform_name": "美团",
|
||||
"price": 23.8,
|
||||
},
|
||||
{
|
||||
"platform_id": "taobao",
|
||||
"platform_name": "淘宝",
|
||||
"price": 21.5,
|
||||
},
|
||||
{
|
||||
"platform_id": "jd",
|
||||
"platform_name": "京东",
|
||||
"price": 22.2,
|
||||
},
|
||||
],
|
||||
skipped_dish_names=[],
|
||||
saved_amount_cents=230,
|
||||
raw_payload={"demo": True},
|
||||
created_at=local_now + timedelta(seconds=index * 2),
|
||||
)
|
||||
)
|
||||
db.add_all(rows)
|
||||
db.commit()
|
||||
risk_repo.sync_compare_incident(db, user_id=user.id, at=local_now)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if settings.APP_ENV != "dev":
|
||||
raise SystemExit("拒绝执行:风控演示数据脚本仅允许 APP_ENV=dev")
|
||||
now = risk_repo.utcnow().replace(minute=10, second=0, microsecond=0)
|
||||
with SessionLocal() as db:
|
||||
_reset_demo(db)
|
||||
_ensure_admin(db)
|
||||
_seed_sms(db, now)
|
||||
_seed_oneclick(db, now)
|
||||
_seed_compare(db, now)
|
||||
print("风控监控演示数据已重建")
|
||||
print(f"后台账号:{ADMIN_USERNAME}")
|
||||
print(f"后台密码:{ADMIN_PASSWORD}")
|
||||
print("期望卡片:短信 3 / 1284;一键登录 2 / 963;比价 2 / 2105")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user