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>
164 lines
6.3 KiB
Python
164 lines
6.3 KiB
Python
"""通用行为事件、风险事件与主体限制。
|
|
|
|
这三张表不是“风控监控页面专用表”:
|
|
|
|
- ``behavior_event`` 保存服务端权威的关键行为流水,后续可复用于安全审计、
|
|
漏斗核查和客诉排查;
|
|
- ``risk_incident`` 保存规则命中后的可处置事件,负责“待处理/忽略/封禁/解除”
|
|
生命周期;
|
|
- ``subject_restriction`` 保存当前生效的主体限制,统一承载设备、账号等主体的
|
|
业务拦截状态。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import (
|
|
JSON,
|
|
Boolean,
|
|
DateTime,
|
|
Index,
|
|
Integer,
|
|
String,
|
|
UniqueConstraint,
|
|
func,
|
|
true,
|
|
)
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from app.db.base import Base
|
|
|
|
_JSON = JSON().with_variant(JSONB(), "postgresql")
|
|
|
|
|
|
class BehaviorEvent(Base):
|
|
"""服务端权威行为流水;只增不改。"""
|
|
|
|
__tablename__ = "behavior_event"
|
|
__table_args__ = (
|
|
Index("ix_behavior_event_type_time", "event_type", "occurred_at"),
|
|
Index(
|
|
"ix_behavior_event_subject_time",
|
|
"subject_type",
|
|
"subject_id",
|
|
"occurred_at",
|
|
),
|
|
Index("ix_behavior_event_user_time", "user_id", "occurred_at"),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
subject_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
subject_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
|
|
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
device_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
device_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
client_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
# success / failed / rejected / attempted
|
|
outcome: Mapped[str] = mapped_column(String(24), nullable=False, default="success")
|
|
reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
|
details: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
|
|
|
occurred_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, index=True
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
)
|
|
|
|
|
|
class RiskIncident(Base):
|
|
"""规则命中后形成的可处置风险事件。"""
|
|
|
|
__tablename__ = "risk_incident"
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"rule_code",
|
|
"subject_type",
|
|
"subject_id",
|
|
"window_key",
|
|
name="uq_risk_incident_rule_subject_window",
|
|
),
|
|
Index("ix_risk_incident_rule_status", "rule_code", "status", "triggered_at"),
|
|
Index(
|
|
"ix_risk_incident_subject",
|
|
"subject_type",
|
|
"subject_id",
|
|
"triggered_at",
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
rule_code: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
subject_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
subject_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
window_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
|
|
window_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
window_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
first_event_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
triggered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
last_event_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
event_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
|
|
# open / ignored / blocked / resolved
|
|
status: Mapped[str] = mapped_column(String(24), nullable=False, default="open")
|
|
action_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
|
handled_by: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
handled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
details: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
onupdate=func.now(),
|
|
nullable=False,
|
|
)
|
|
|
|
|
|
class SubjectRestriction(Base):
|
|
"""主体当前限制状态;同一主体同一作用域只有一条,可封禁后再解除/重启。"""
|
|
|
|
__tablename__ = "subject_restriction"
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"subject_type",
|
|
"subject_id",
|
|
"scope",
|
|
name="uq_subject_restriction_subject_scope",
|
|
),
|
|
Index(
|
|
"ix_subject_restriction_lookup",
|
|
"subject_type",
|
|
"subject_id",
|
|
"scope",
|
|
"active",
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
subject_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
subject_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
# auth_device / economic_account / all
|
|
scope: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
active: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, default=True, server_default=true()
|
|
)
|
|
reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
|
incident_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
created_by: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
)
|
|
revoked_by: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
details: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|